@kyun.si/agent2cursor 0.0.0-stage → 0.1.0
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.
- package/CONTRIBUTING.md +89 -0
- package/LICENSE +21 -0
- package/README.md +202 -2
- package/README.zh-CN.md +203 -0
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/demos/full-send/LICENSE-tstack +21 -0
- package/demos/full-send/README.md +47 -0
- package/demos/full-send/TSTACK-PROVENANCE.json +37 -0
- package/demos/full-send/bot/agent.ts +9 -0
- package/demos/full-send/bot/channels/github.ts +16 -0
- package/demos/full-send/bot/instructions.md +11 -0
- package/demos/full-send/bot/lib/merge-policy.ts +9 -0
- package/demos/full-send/bot/lib/settings.ts +2 -0
- package/demos/full-send/bot/skills/babysit-pr/SKILL.md +13 -0
- package/demos/full-send/bot/skills/babysit-pr/scripts/pr-wait +556 -0
- package/demos/full-send/bot/skills/file-pr/SKILL.md +11 -0
- package/demos/full-send/bot/skills/full-send/SKILL.md +19 -0
- package/demos/full-send/bot/skills/leaving-pr-comments/SKILL.md +14 -0
- package/demos/full-send/bot/skills/sol-loop/SKILL.md +11 -0
- package/demos/full-send/bot/subagents/reviewer/agent.ts +2 -0
- package/demos/full-send/bot/subagents/reviewer/instructions.md +3 -0
- package/demos/full-send/bot/tools/merge_pr.ts +23 -0
- package/demos/full-send/bot/tools/pr_wait.ts +24 -0
- package/demos/full-send/bot/tools/pr_wait_script.ts +9 -0
- package/demos/full-send/evals/evals.config.ts +2 -0
- package/demos/full-send/evals/full-send.eval.ts +5 -0
- package/demos/full-send/evals/skills.eval.ts +8 -0
- package/demos/full-send/package.json +18 -0
- package/demos/pr-review/LICENSE-tstack +21 -0
- package/demos/pr-review/README.md +44 -0
- package/demos/pr-review/TSTACK-PROVENANCE.json +37 -0
- package/demos/pr-review/bot/agent.ts +9 -0
- package/demos/pr-review/bot/channels/github.ts +22 -0
- package/demos/pr-review/bot/instructions.md +11 -0
- package/demos/pr-review/bot/lib/pr-snapshot.ts +92 -0
- package/demos/pr-review/bot/lib/settings.ts +2 -0
- package/demos/pr-review/bot/skills/council/SKILL.md +25 -0
- package/demos/pr-review/bot/skills/leaving-pr-comments/SKILL.md +14 -0
- package/demos/pr-review/bot/subagents/reviewer-a/agent.ts +2 -0
- package/demos/pr-review/bot/subagents/reviewer-a/instructions.md +3 -0
- package/demos/pr-review/bot/subagents/reviewer-b/agent.ts +2 -0
- package/demos/pr-review/bot/subagents/reviewer-b/instructions.md +3 -0
- package/demos/pr-review/bot/subagents/reviewer-c/agent.ts +2 -0
- package/demos/pr-review/bot/subagents/reviewer-c/instructions.md +3 -0
- package/demos/pr-review/bot/tools/pr_wait.ts +24 -0
- package/demos/pr-review/evals/evals.config.ts +2 -0
- package/demos/pr-review/evals/pr-review.eval.ts +13 -0
- package/demos/pr-review/evals/skills.eval.ts +5 -0
- package/demos/pr-review/package.json +18 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +142 -0
- package/dist/emit/common.d.ts +4 -0
- package/dist/emit/common.js +15 -0
- package/dist/emit/evals.d.ts +4 -0
- package/dist/emit/evals.js +33 -0
- package/dist/emit/project.d.ts +2 -0
- package/dist/emit/project.js +165 -0
- package/dist/emit/report.d.ts +2 -0
- package/dist/emit/report.js +74 -0
- package/dist/emit/subagent.d.ts +2 -0
- package/dist/emit/subagent.js +15 -0
- package/dist/emit/tool-stub.d.ts +3 -0
- package/dist/emit/tool-stub.js +29 -0
- package/dist/emit/tstack-license.d.ts +8 -0
- package/dist/emit/tstack-license.js +39 -0
- package/dist/ir.d.ts +66 -0
- package/dist/ir.js +1 -0
- package/dist/mapping/codex-agent.d.ts +2 -0
- package/dist/mapping/codex-agent.js +39 -0
- package/dist/mapping/codex-skill.d.ts +5 -0
- package/dist/mapping/codex-skill.js +32 -0
- package/dist/mapping/foreign-tools.d.ts +2 -0
- package/dist/mapping/foreign-tools.js +33 -0
- package/dist/mapping/model.d.ts +5 -0
- package/dist/mapping/model.js +14 -0
- package/dist/mapping/names.d.ts +2 -0
- package/dist/mapping/names.js +9 -0
- package/dist/mapping/permissions.d.ts +2 -0
- package/dist/mapping/permissions.js +21 -0
- package/dist/mapping/skill.d.ts +3 -0
- package/dist/mapping/skill.js +29 -0
- package/dist/mapping/subagent.d.ts +3 -0
- package/dist/mapping/subagent.js +60 -0
- package/dist/mapping/tools-names.d.ts +6 -0
- package/dist/mapping/tools-names.js +18 -0
- package/dist/scan/codex.d.ts +1 -0
- package/dist/scan/codex.js +19 -0
- package/dist/scan/frontmatter.d.ts +11 -0
- package/dist/scan/frontmatter.js +62 -0
- package/dist/scan/index.d.ts +2 -0
- package/dist/scan/index.js +192 -0
- package/dist/scan/safety.d.ts +11 -0
- package/dist/scan/safety.js +105 -0
- package/dist/scan/tstack.d.ts +9 -0
- package/dist/scan/tstack.js +42 -0
- package/dist/validate.d.ts +5 -0
- package/dist/validate.js +135 -0
- package/dist/write.d.ts +19 -0
- package/dist/write.js +134 -0
- package/docs/acceptance.md +86 -0
- package/docs/assets/agent2cursor-demo-poster.png +0 -0
- package/docs/assets/agent2cursor-demo.gif +0 -0
- package/docs/evidence/codex-adapter.json +133 -0
- package/docs/evidence/evaluation-fixes.json +184 -0
- package/docs/evidence/independent-evaluation.json +61 -0
- package/docs/evidence/online-2026-10-27.json +112 -0
- package/docs/official-examples.provenance.json +26 -0
- package/docs/upstream-verification.md +67 -0
- package/evaluation/20261027-2216//345/244/215/346/265/213/347/273/223/350/256/272.md +48 -0
- package/evaluation/README.md +16 -0
- package/package.json +30 -4
- package/templates/en/instructions.md +9 -0
- package/templates/zh/instructions.md +9 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pr-wait: watch one GitHub pull request and print one line per real event.
|
|
3
|
+
|
|
4
|
+
pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval 20]
|
|
5
|
+
|
|
6
|
+
<pr> is a PR number or PR URL. Without --repo, the repo comes from the URL or
|
|
7
|
+
from the current checkout. Stdout carries only events, one line each, so a
|
|
8
|
+
monitor (Claude Code's Monitor tool) wakes the agent once per real event.
|
|
9
|
+
Progress and errors that do not end the run go to stderr.
|
|
10
|
+
|
|
11
|
+
--until
|
|
12
|
+
green required checks passed on the current head (every CI check when
|
|
13
|
+
the base branch requires none)
|
|
14
|
+
ready green, Greptile or Macroscope reviewed the current head, and no
|
|
15
|
+
review thread is unresolved (default)
|
|
16
|
+
any-change stop after the first poll that prints an event
|
|
17
|
+
|
|
18
|
+
Exit codes
|
|
19
|
+
0 the --until condition is met, or the PR merged
|
|
20
|
+
1 the condition cannot be met without action: a required check failed, the
|
|
21
|
+
branch conflicts with its base, the PR closed, `ready` is blocked by
|
|
22
|
+
unresolved threads or a BEHIND branch, or GitHub stayed unreachable
|
|
23
|
+
2 bad arguments, unknown PR, or another pr-wait already watches this PR
|
|
24
|
+
|
|
25
|
+
Events
|
|
26
|
+
- a required check failed (name and link)
|
|
27
|
+
- all checks finished on the head (one summary line)
|
|
28
|
+
- a new comment, review, thread, or thread reply from someone other than the
|
|
29
|
+
PR author or the gh user
|
|
30
|
+
- Greptile, Macroscope, or CodeRabbit finished on the head, or a Greptile or
|
|
31
|
+
Macroscope summary changed after that
|
|
32
|
+
- the base branch moved and the new commits touch files in this PR
|
|
33
|
+
- merge state became BEHIND or DIRTY
|
|
34
|
+
- the PR merged or closed
|
|
35
|
+
|
|
36
|
+
CodeRabbit is advisory: pr-wait reports it but never waits for it. Comment
|
|
37
|
+
edits are never events, and top-level comments from CI and deploy bots
|
|
38
|
+
(github-actions, Vercel) are skipped, because checks already carry that news.
|
|
39
|
+
|
|
40
|
+
State lives in ~/.local/state/pr-wait/. A lock makes a second watcher for the
|
|
41
|
+
same PR exit at once. A state file lets a restarted watcher report what arrived
|
|
42
|
+
while no watcher ran. The first run for a PR records what already exists and
|
|
43
|
+
reports only what comes after.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import argparse
|
|
49
|
+
import fcntl
|
|
50
|
+
import hashlib
|
|
51
|
+
import html
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import re
|
|
55
|
+
import subprocess
|
|
56
|
+
import sys
|
|
57
|
+
import time
|
|
58
|
+
from collections.abc import Sequence
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
from typing import Any, NoReturn
|
|
61
|
+
|
|
62
|
+
GATING_BOTS = ("greptile", "macroscope")
|
|
63
|
+
ADVISORY_BOTS = ("coderabbit",)
|
|
64
|
+
# CI and deploy bots report through checks. Their PR comments are status reports.
|
|
65
|
+
STATUS_BOTS = ("github-actions", "vercel", "netlify", "railway", "cloudflare")
|
|
66
|
+
STATE_DIR = Path.home() / ".local" / "state" / "pr-wait"
|
|
67
|
+
# A state file older than this describes an old session. Start a fresh baseline
|
|
68
|
+
# instead of replaying hours of comments as new events.
|
|
69
|
+
STALE_STATE_SECONDS = 6 * 3600
|
|
70
|
+
MAX_FAILED_POLLS = 10
|
|
71
|
+
TRANSIENT = re.compile(
|
|
72
|
+
r"HTTP (5\d\d|429)|rate limit|timed? ?out|connection (reset|refused)|EOF|"
|
|
73
|
+
r"temporar|could not resolve host|TLS handshake|Something went wrong",
|
|
74
|
+
re.IGNORECASE,
|
|
75
|
+
)
|
|
76
|
+
FAILED = ("fail", "cancel")
|
|
77
|
+
# gh pr checks buckets, worst first. Duplicate names (push and pull_request runs
|
|
78
|
+
# of one job) collapse to the worst bucket.
|
|
79
|
+
RANK = {"fail": 4, "cancel": 3, "pending": 2, "pass": 1, "skipping": 0}
|
|
80
|
+
|
|
81
|
+
QUERY = """
|
|
82
|
+
query($owner: String!, $name: String!, $number: Int!) {
|
|
83
|
+
viewer { login }
|
|
84
|
+
repository(owner: $owner, name: $name) {
|
|
85
|
+
pullRequest(number: $number) {
|
|
86
|
+
url state headRefOid baseRefName mergeStateStatus
|
|
87
|
+
author { login }
|
|
88
|
+
baseRef { target { oid } }
|
|
89
|
+
comments(last: 100) { nodes { id url body author { login __typename } } }
|
|
90
|
+
reviews(last: 100) {
|
|
91
|
+
nodes { id url state body commit { oid } author { login __typename } }
|
|
92
|
+
}
|
|
93
|
+
reviewThreads(last: 100) {
|
|
94
|
+
nodes {
|
|
95
|
+
isResolved path line
|
|
96
|
+
comments(first: 100) { nodes { id url body author { login __typename } } }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
Json = dict[str, Any]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class GhError(Exception):
|
|
108
|
+
def __init__(self, message: str, transient: bool) -> None:
|
|
109
|
+
super().__init__(message)
|
|
110
|
+
self.transient = transient
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def log(message: str) -> None:
|
|
114
|
+
print(f"pr-wait: {message}", file=sys.stderr, flush=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def fail(message: str, code: int = 2) -> NoReturn:
|
|
118
|
+
# Fatal messages go to stdout so a monitor shows the agent why it stopped.
|
|
119
|
+
print(f"pr-wait: {message}", flush=True)
|
|
120
|
+
sys.exit(code)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def gh(args: list[str], allow: tuple[int, ...] = (0,)) -> subprocess.CompletedProcess[str]:
|
|
124
|
+
"""Run gh. Retries transient failures (HTTP 5xx or 429, network) with backoff."""
|
|
125
|
+
delay = 2
|
|
126
|
+
for attempt in range(5):
|
|
127
|
+
proc = subprocess.run(["gh", *args], capture_output=True, text=True)
|
|
128
|
+
transient = proc.returncode != 0 and bool(TRANSIENT.search(proc.stderr))
|
|
129
|
+
if not transient and proc.returncode in allow:
|
|
130
|
+
return proc
|
|
131
|
+
lines = proc.stderr.strip().splitlines()
|
|
132
|
+
message = lines[-1] if lines else f"gh exited {proc.returncode}"
|
|
133
|
+
if not transient or attempt == 4:
|
|
134
|
+
raise GhError(message, transient)
|
|
135
|
+
log(f"gh {' '.join(args[:2])}: {message}; retry in {delay}s")
|
|
136
|
+
time.sleep(delay)
|
|
137
|
+
delay *= 2
|
|
138
|
+
raise AssertionError("unreachable")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def gh_lines(args: list[str]) -> list[str]:
|
|
142
|
+
return [line for line in gh(args).stdout.splitlines() if line]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def bot_name(text: str | None) -> str | None:
|
|
146
|
+
"""Which review bot a login or check name belongs to, if any."""
|
|
147
|
+
lowered = (text or "").lower()
|
|
148
|
+
return next((bot for bot in GATING_BOTS + ADVISORY_BOTS if bot in lowered), None)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def who(node: Json) -> tuple[str, bool]:
|
|
152
|
+
author = node.get("author") or {}
|
|
153
|
+
return author.get("login") or "ghost", author.get("__typename") == "Bot"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def plain(body: str) -> str:
|
|
157
|
+
body = re.sub(r"<!--.*?-->", " ", body or "", flags=re.DOTALL)
|
|
158
|
+
return html.unescape(re.sub(r"<[^>]+>", " ", body))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def snippet(body: str, limit: int = 100) -> str:
|
|
162
|
+
text = re.sub(r"\s+", " ", plain(body)).strip()
|
|
163
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def verdict(body: str) -> str:
|
|
167
|
+
"""The verdict line of a bot summary (Macroscope, Greptile), if it has one."""
|
|
168
|
+
for line in plain(body).splitlines():
|
|
169
|
+
if re.search(r"verdict|confidence score", line, re.IGNORECASE):
|
|
170
|
+
return snippet(line.replace("*", ""), 80)
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def digest(body: str) -> str:
|
|
175
|
+
return hashlib.sha1(re.sub(r"\s+", " ", plain(body)).encode()).hexdigest()[:12]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def parse_target(raw: str | None, repo: str | None) -> tuple[str, int]:
|
|
179
|
+
if raw is None or not raw.strip():
|
|
180
|
+
fail("missing PR number. Did a variable expand to nothing? Usage: pr-wait <pr> [--repo owner/name]")
|
|
181
|
+
url = re.fullmatch(r"https?://github\.com/([^/\s]+/[^/\s]+)/pull/(\d+)(?:[/?#]\S*)?", raw.strip())
|
|
182
|
+
number = re.fullmatch(r"#?(\d+)", raw.strip())
|
|
183
|
+
if url:
|
|
184
|
+
repo = repo or url.group(1)
|
|
185
|
+
pr = int(url.group(2))
|
|
186
|
+
elif number:
|
|
187
|
+
pr = int(number.group(1))
|
|
188
|
+
else:
|
|
189
|
+
fail(f"'{raw}' is not a PR number or PR URL")
|
|
190
|
+
if pr <= 0:
|
|
191
|
+
fail(f"'{raw}' is not a valid PR number")
|
|
192
|
+
if not repo:
|
|
193
|
+
try:
|
|
194
|
+
repo = gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]).stdout.strip()
|
|
195
|
+
except GhError as error:
|
|
196
|
+
fail(f"no --repo given and the current directory has no GitHub repo ({error})")
|
|
197
|
+
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
|
|
198
|
+
fail(f"'{repo}' is not owner/name")
|
|
199
|
+
try:
|
|
200
|
+
gh(["pr", "view", str(pr), "-R", repo, "--json", "number"])
|
|
201
|
+
except GhError as error:
|
|
202
|
+
if not error.transient: # a transient failure is retried by the poll loop
|
|
203
|
+
fail(f"cannot read {repo}#{pr}: {error}")
|
|
204
|
+
return repo, pr
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def lock(repo: str, pr: int) -> Any:
|
|
208
|
+
"""Hold a per-PR lock for the life of the process. The OS drops it on exit."""
|
|
209
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
handle = open(STATE_DIR / f"{repo.replace('/', '__')}__{pr}.lock", "a+")
|
|
211
|
+
try:
|
|
212
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
213
|
+
except BlockingIOError:
|
|
214
|
+
handle.seek(0)
|
|
215
|
+
pid = handle.read().strip() or "unknown"
|
|
216
|
+
fail(
|
|
217
|
+
f"pid {pid} already watches {repo}#{pr}, so this watcher exits to avoid "
|
|
218
|
+
f"duplicate events. If no events reach you from it, run `kill {pid}` and start again."
|
|
219
|
+
)
|
|
220
|
+
handle.seek(0)
|
|
221
|
+
handle.truncate()
|
|
222
|
+
handle.write(str(os.getpid()))
|
|
223
|
+
handle.flush()
|
|
224
|
+
return handle
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def required_checks(repo: str, base: str) -> set[str]:
|
|
228
|
+
"""Required check names for the base branch, from rulesets and classic branch
|
|
229
|
+
protection. Knowing them up front means a required check that has not started
|
|
230
|
+
yet counts as pending, instead of green by omission."""
|
|
231
|
+
sources = (
|
|
232
|
+
(
|
|
233
|
+
f"repos/{repo}/rules/branches/{base}",
|
|
234
|
+
'.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context',
|
|
235
|
+
),
|
|
236
|
+
(f"repos/{repo}/branches/{base}/protection/required_status_checks", ".contexts[]?, .checks[]?.context"),
|
|
237
|
+
)
|
|
238
|
+
names: set[str] = set()
|
|
239
|
+
for path, jq in sources:
|
|
240
|
+
try:
|
|
241
|
+
names.update(gh_lines(["api", path, "--jq", jq]))
|
|
242
|
+
except GhError as error:
|
|
243
|
+
if "HTTP 404" not in str(error): # 404 means no protection, which is normal
|
|
244
|
+
log(f"could not read required checks from {path}: {error}")
|
|
245
|
+
return names
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class Watcher:
|
|
249
|
+
def __init__(self, repo: str, pr: int, until: str, interval: int) -> None:
|
|
250
|
+
self.repo, self.pr, self.until, self.interval = repo, pr, until, interval
|
|
251
|
+
self.owner, self.name = repo.split("/")
|
|
252
|
+
self.path = STATE_DIR / f"{repo.replace('/', '__')}__{pr}.json"
|
|
253
|
+
state = self.load()
|
|
254
|
+
# First run for this PR: record what exists and report nothing.
|
|
255
|
+
self.baseline = state is None
|
|
256
|
+
self.state: Json = state or {"reported": [], "summaries": {}, "base_oid": None, "merge_state": None}
|
|
257
|
+
self.reported: set[str] = set(self.state["reported"])
|
|
258
|
+
self.printed: set[str] = set()
|
|
259
|
+
self.events = 0
|
|
260
|
+
self.required: set[str] | None = None
|
|
261
|
+
self.prev_snapshot: list[tuple[str, str]] | None = None
|
|
262
|
+
self.pr_files: dict[str, set[str]] = {}
|
|
263
|
+
|
|
264
|
+
def load(self) -> Json | None:
|
|
265
|
+
try:
|
|
266
|
+
if time.time() - self.path.stat().st_mtime > STALE_STATE_SECONDS:
|
|
267
|
+
return None
|
|
268
|
+
return json.loads(self.path.read_text())
|
|
269
|
+
except (OSError, ValueError):
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
def save(self) -> None:
|
|
273
|
+
self.state["reported"] = sorted(self.reported)
|
|
274
|
+
tmp = self.path.with_suffix(".tmp")
|
|
275
|
+
tmp.write_text(json.dumps(self.state))
|
|
276
|
+
tmp.replace(self.path)
|
|
277
|
+
|
|
278
|
+
def say(self, line: str) -> None:
|
|
279
|
+
if line not in self.printed:
|
|
280
|
+
self.printed.add(line)
|
|
281
|
+
print(f"#{self.pr} {line}", flush=True)
|
|
282
|
+
|
|
283
|
+
def emit(self, key: str, line: str) -> None:
|
|
284
|
+
"""Print an event once per PR, across restarts."""
|
|
285
|
+
if key in self.reported:
|
|
286
|
+
return
|
|
287
|
+
self.reported.add(key)
|
|
288
|
+
if not self.baseline:
|
|
289
|
+
self.events += 1
|
|
290
|
+
self.say(line)
|
|
291
|
+
|
|
292
|
+
def finish(self, code: int, lines: Sequence[str] = ()) -> NoReturn:
|
|
293
|
+
for line in lines:
|
|
294
|
+
self.say(line)
|
|
295
|
+
self.save()
|
|
296
|
+
sys.exit(code)
|
|
297
|
+
|
|
298
|
+
def fetch_pr(self) -> Json:
|
|
299
|
+
proc = gh([
|
|
300
|
+
"api", "graphql", "-f", f"query={QUERY}",
|
|
301
|
+
"-f", f"owner={self.owner}", "-f", f"name={self.name}", "-F", f"number={self.pr}",
|
|
302
|
+
])
|
|
303
|
+
data = json.loads(proc.stdout)["data"]
|
|
304
|
+
pr = data["repository"]["pullRequest"]
|
|
305
|
+
pr["viewer"] = data["viewer"]["login"]
|
|
306
|
+
return pr
|
|
307
|
+
|
|
308
|
+
def fetch_checks(self) -> dict[str, Json]:
|
|
309
|
+
# gh pr checks exits 8 while checks are pending and 1 when one failed.
|
|
310
|
+
# Those are states, not errors: read the JSON and ignore the exit code.
|
|
311
|
+
proc = gh(["pr", "checks", str(self.pr), "-R", self.repo, "--json", "name,bucket,link,description"], allow=(0, 1, 8))
|
|
312
|
+
if not proc.stdout.strip():
|
|
313
|
+
if "no checks reported" in proc.stderr:
|
|
314
|
+
return {}
|
|
315
|
+
raise GhError(proc.stderr.strip() or f"gh pr checks exited {proc.returncode}", False)
|
|
316
|
+
checks: dict[str, Json] = {}
|
|
317
|
+
for row in json.loads(proc.stdout):
|
|
318
|
+
current = checks.get(row["name"])
|
|
319
|
+
if current is None or RANK.get(row["bucket"], 2) > RANK.get(current["bucket"], 2):
|
|
320
|
+
checks[row["name"]] = row
|
|
321
|
+
return checks
|
|
322
|
+
|
|
323
|
+
def base_overlap(self, head: str, old: str, new: str) -> list[str]:
|
|
324
|
+
"""Files this PR touches that the new base commits also touch."""
|
|
325
|
+
try:
|
|
326
|
+
if head not in self.pr_files:
|
|
327
|
+
self.pr_files[head] = set(gh_lines(["pr", "diff", str(self.pr), "-R", self.repo, "--name-only"]))
|
|
328
|
+
moved = gh_lines(["api", f"repos/{self.repo}/compare/{old}...{new}", "--jq", ".files[].filename"])
|
|
329
|
+
except GhError as error:
|
|
330
|
+
log(f"could not compare base commits: {error}")
|
|
331
|
+
return []
|
|
332
|
+
return sorted(self.pr_files[head].intersection(moved))
|
|
333
|
+
|
|
334
|
+
def behind(self, base: str, base_oid: str | None, head: str) -> str:
|
|
335
|
+
"""How far the head is behind its base, for the green and ready lines."""
|
|
336
|
+
if not base_oid:
|
|
337
|
+
return ""
|
|
338
|
+
try:
|
|
339
|
+
count = gh(["api", f"repos/{self.repo}/compare/{base_oid}...{head}", "--jq", ".behind_by"]).stdout.strip()
|
|
340
|
+
except GhError:
|
|
341
|
+
return ""
|
|
342
|
+
return f"; {count} commit{'' if count == '1' else 's'} behind {base}"
|
|
343
|
+
|
|
344
|
+
def poll(self) -> None:
|
|
345
|
+
pr = self.fetch_pr()
|
|
346
|
+
if pr["state"] == "MERGED":
|
|
347
|
+
self.finish(0, [f"merged: {pr['url']}"])
|
|
348
|
+
if pr["state"] == "CLOSED":
|
|
349
|
+
self.finish(1, [f"closed without merging: {pr['url']}"])
|
|
350
|
+
|
|
351
|
+
base = pr["baseRefName"]
|
|
352
|
+
if self.required is None:
|
|
353
|
+
self.required = required_checks(self.repo, base)
|
|
354
|
+
gate = ", ".join(sorted(self.required)) or "none set, so every CI check gates"
|
|
355
|
+
log(f"watching {self.repo}#{self.pr} until {self.until}; required checks: {gate}")
|
|
356
|
+
checks = self.fetch_checks()
|
|
357
|
+
head = pr["headRefOid"]
|
|
358
|
+
short = head[:7]
|
|
359
|
+
mine = {who(pr)[0], pr["viewer"]}
|
|
360
|
+
comments = pr["comments"]["nodes"]
|
|
361
|
+
reviews = pr["reviews"]["nodes"]
|
|
362
|
+
threads = pr["reviewThreads"]["nodes"]
|
|
363
|
+
self.events = 0
|
|
364
|
+
|
|
365
|
+
# Required checks. With no required checks configured, every CI check
|
|
366
|
+
# gates except the review bots' own checks.
|
|
367
|
+
gate = self.required or {name for name in checks if not bot_name(name)}
|
|
368
|
+
label = "required check" if self.required else "check"
|
|
369
|
+
|
|
370
|
+
def bucket(name: str) -> str:
|
|
371
|
+
return checks[name]["bucket"] if name in checks else "pending"
|
|
372
|
+
|
|
373
|
+
fail_lines: list[str] = []
|
|
374
|
+
for name in sorted(gate):
|
|
375
|
+
if bucket(name) in FAILED:
|
|
376
|
+
link = checks[name]["link"]
|
|
377
|
+
fail_lines.append(f"{label} failed: {name} {link}")
|
|
378
|
+
self.emit(f"fail:{head}:{name}:{link}", fail_lines[-1])
|
|
379
|
+
watched = {name: c for name, c in checks.items() if bot_name(name) not in ADVISORY_BOTS}
|
|
380
|
+
gate_done = bool(gate) and all(bucket(n) != "pending" for n in gate)
|
|
381
|
+
all_done = gate_done and all(c["bucket"] != "pending" for c in watched.values())
|
|
382
|
+
# Checks appear a few seconds after a push. Two identical polls in a row
|
|
383
|
+
# guard against calling a half-registered set of checks finished.
|
|
384
|
+
snapshot = sorted((name, c["bucket"]) for name, c in watched.items())
|
|
385
|
+
stable = snapshot == self.prev_snapshot
|
|
386
|
+
self.prev_snapshot = snapshot
|
|
387
|
+
green = gate_done and stable and not fail_lines
|
|
388
|
+
|
|
389
|
+
# People and bots talking. Edits are never events.
|
|
390
|
+
for comment in comments:
|
|
391
|
+
login, is_bot = who(comment)
|
|
392
|
+
if login in mine or bot_name(login) or (is_bot and any(b in login for b in STATUS_BOTS)):
|
|
393
|
+
continue
|
|
394
|
+
self.emit(f"c:{comment['id']}", f"comment from {login}: {snippet(comment['body'])} {comment['url']}")
|
|
395
|
+
for review in reviews:
|
|
396
|
+
login, is_bot = who(review)
|
|
397
|
+
if login in mine or is_bot:
|
|
398
|
+
continue
|
|
399
|
+
if review["state"] in ("APPROVED", "CHANGES_REQUESTED") or review["body"].strip():
|
|
400
|
+
state = review["state"].lower().replace("_", " ")
|
|
401
|
+
self.emit(f"c:{review['id']}", f"review from {login} ({state}): {snippet(review['body'])} {review['url']}")
|
|
402
|
+
unresolved: list[str] = []
|
|
403
|
+
for thread in threads:
|
|
404
|
+
nodes = thread["comments"]["nodes"]
|
|
405
|
+
if not thread["isResolved"] and nodes:
|
|
406
|
+
unresolved.append(nodes[0]["url"])
|
|
407
|
+
where = f"{thread['path']}:{thread['line']}" if thread["line"] else thread["path"]
|
|
408
|
+
for index, comment in enumerate(nodes):
|
|
409
|
+
login, _ = who(comment)
|
|
410
|
+
if login in mine:
|
|
411
|
+
continue
|
|
412
|
+
kind = "new thread" if index == 0 else "thread reply"
|
|
413
|
+
self.emit(f"c:{comment['id']}", f"{kind} from {login} on {where}: {snippet(comment['body'])} {comment['url']}")
|
|
414
|
+
|
|
415
|
+
# Review bots on the current head. A bot with checks is done when they
|
|
416
|
+
# finish. Otherwise it is done once it reviews the head commit or its
|
|
417
|
+
# summary names the head.
|
|
418
|
+
bot_status: dict[str, str] = {}
|
|
419
|
+
reviewed: list[str] = []
|
|
420
|
+
for bot in GATING_BOTS + ADVISORY_BOTS:
|
|
421
|
+
own_checks = {name: c for name, c in checks.items() if bot_name(name) == bot}
|
|
422
|
+
summaries = [c for c in comments if bot_name(who(c)[0]) == bot]
|
|
423
|
+
own_reviews = [r for r in reviews if bot_name(who(r)[0]) == bot]
|
|
424
|
+
on_head = any((r.get("commit") or {}).get("oid") == head for r in own_reviews)
|
|
425
|
+
if own_checks:
|
|
426
|
+
done = all(c["bucket"] != "pending" for c in own_checks.values())
|
|
427
|
+
else:
|
|
428
|
+
done = on_head or any(short in s["body"] for s in summaries)
|
|
429
|
+
seen = bool(own_checks or summaries or own_reviews)
|
|
430
|
+
if bot in ADVISORY_BOTS and own_checks:
|
|
431
|
+
desc = next(iter(own_checks.values()))
|
|
432
|
+
bot_status[bot] = f"{bot} {desc['description'] or desc['bucket']} (advisory)"
|
|
433
|
+
elif seen:
|
|
434
|
+
bot_status[bot] = f"{bot} {'done' if done else 'pending'}{' (advisory)' if bot in ADVISORY_BOTS else ''}"
|
|
435
|
+
key = f"bot:{bot}:{head}"
|
|
436
|
+
was_done = key in self.reported
|
|
437
|
+
if done:
|
|
438
|
+
if bot in GATING_BOTS:
|
|
439
|
+
reviewed.append(bot)
|
|
440
|
+
failed = [name for name, c in own_checks.items() if c["bucket"] in FAILED]
|
|
441
|
+
detail = "; ".join(filter(None, [
|
|
442
|
+
next((v for v in map(verdict, (s["body"] for s in summaries)) if v), ""),
|
|
443
|
+
f"failed: {', '.join(failed)}" if failed else "",
|
|
444
|
+
]))
|
|
445
|
+
tag = " (advisory, not waited on)" if bot in ADVISORY_BOTS else ""
|
|
446
|
+
self.emit(key, f"{bot} finished on {short}{': ' + detail if detail else ''}{tag}")
|
|
447
|
+
if bot in GATING_BOTS:
|
|
448
|
+
for s in summaries:
|
|
449
|
+
new, old = digest(s["body"]), self.state["summaries"].get(s["id"])
|
|
450
|
+
self.state["summaries"][s["id"]] = new
|
|
451
|
+
if was_done and old and old != new:
|
|
452
|
+
text = verdict(s["body"]) or snippet(s["body"])
|
|
453
|
+
self.emit(f"summary:{s['id']}:{new}", f"{bot} summary changed on {short}: {text} {s['url']}")
|
|
454
|
+
|
|
455
|
+
# Base branch and merge state.
|
|
456
|
+
base_oid = ((pr.get("baseRef") or {}).get("target") or {}).get("oid")
|
|
457
|
+
old_base = self.state["base_oid"]
|
|
458
|
+
if base_oid and old_base and base_oid != old_base and not self.baseline:
|
|
459
|
+
touched = self.base_overlap(head, old_base, base_oid)
|
|
460
|
+
if touched:
|
|
461
|
+
more = f" and {len(touched) - 5} more" if len(touched) > 5 else ""
|
|
462
|
+
self.emit(
|
|
463
|
+
f"base:{base_oid}",
|
|
464
|
+
f"{base} moved to {base_oid[:7]} and touches files in this PR: {', '.join(touched[:5])}{more}",
|
|
465
|
+
)
|
|
466
|
+
if base_oid:
|
|
467
|
+
self.state["base_oid"] = base_oid
|
|
468
|
+
merge_state = pr["mergeStateStatus"]
|
|
469
|
+
dirty_line = f"merge state is DIRTY: the branch conflicts with {base}, rebase it"
|
|
470
|
+
behind_line = f"merge state is BEHIND: update the branch from {base} before merging"
|
|
471
|
+
if merge_state != "UNKNOWN":
|
|
472
|
+
if merge_state in ("BEHIND", "DIRTY") and self.state["merge_state"] != merge_state:
|
|
473
|
+
line = dirty_line if merge_state == "DIRTY" else behind_line
|
|
474
|
+
self.emit(f"merge:{merge_state}:{head}:{base_oid}", line)
|
|
475
|
+
self.state["merge_state"] = merge_state
|
|
476
|
+
|
|
477
|
+
# One summary line per head once all checks finish.
|
|
478
|
+
counts: dict[str, int] = {}
|
|
479
|
+
for c in watched.values():
|
|
480
|
+
counts[c["bucket"]] = counts.get(c["bucket"], 0) + 1
|
|
481
|
+
failed_names = sorted(name for name, c in watched.items() if c["bucket"] in FAILED)
|
|
482
|
+
parts = [
|
|
483
|
+
", ".join(f"{n} {b}" for b, n in sorted(counts.items()))
|
|
484
|
+
+ (f" ({', '.join(failed_names)})" if failed_names else ""),
|
|
485
|
+
f"required {sum(bucket(n) in ('pass', 'skipping') for n in self.required)}/{len(self.required)} green"
|
|
486
|
+
if self.required
|
|
487
|
+
else "",
|
|
488
|
+
", ".join(bot_status[b] for b in GATING_BOTS + ADVISORY_BOTS if b in bot_status)
|
|
489
|
+
or "no Greptile or Macroscope yet",
|
|
490
|
+
f"{len(unresolved)} unresolved threads",
|
|
491
|
+
f"merge state {merge_state}",
|
|
492
|
+
]
|
|
493
|
+
summary = "; ".join(p for p in parts if p)
|
|
494
|
+
checks_key = f"checks:{head}"
|
|
495
|
+
|
|
496
|
+
# Decide whether to stop. A green or ready line carries the same summary
|
|
497
|
+
# as the checks line, so it marks that event as reported.
|
|
498
|
+
if self.until != "any-change":
|
|
499
|
+
if merge_state == "DIRTY":
|
|
500
|
+
self.finish(1, [dirty_line])
|
|
501
|
+
if fail_lines:
|
|
502
|
+
self.finish(1, fail_lines)
|
|
503
|
+
if green and self.until == "green":
|
|
504
|
+
self.reported.add(checks_key)
|
|
505
|
+
self.finish(0, [f"green on {short}: {summary}{self.behind(base, base_oid, head)}"])
|
|
506
|
+
# ready waits for Greptile or Macroscope, never for CodeRabbit.
|
|
507
|
+
if green and reviewed:
|
|
508
|
+
if unresolved:
|
|
509
|
+
self.finish(1, [f"blocked by {len(unresolved)} unresolved threads on {short}: {' '.join(unresolved)}"])
|
|
510
|
+
if merge_state == "BEHIND":
|
|
511
|
+
self.finish(1, [behind_line])
|
|
512
|
+
self.reported.add(checks_key)
|
|
513
|
+
self.finish(0, [f"ready on {short}: {summary}{self.behind(base, base_oid, head)}"])
|
|
514
|
+
if all_done and (stable or self.baseline):
|
|
515
|
+
self.emit(checks_key, f"checks finished on {short}: {summary}")
|
|
516
|
+
if self.until == "any-change" and self.events:
|
|
517
|
+
self.finish(0)
|
|
518
|
+
|
|
519
|
+
def run(self) -> NoReturn:
|
|
520
|
+
failures = 0
|
|
521
|
+
while True:
|
|
522
|
+
try:
|
|
523
|
+
self.poll()
|
|
524
|
+
failures = 0
|
|
525
|
+
except GhError as error:
|
|
526
|
+
if not error.transient:
|
|
527
|
+
self.finish(1, [f"pr-wait stopped: {error}"])
|
|
528
|
+
failures += 1
|
|
529
|
+
log(f"poll failed ({failures}/{MAX_FAILED_POLLS}): {error}")
|
|
530
|
+
if failures >= MAX_FAILED_POLLS:
|
|
531
|
+
self.finish(1, [f"pr-wait stopped: GitHub unreachable for {failures} polls: {error}"])
|
|
532
|
+
else:
|
|
533
|
+
self.save()
|
|
534
|
+
if self.baseline:
|
|
535
|
+
log(f"baseline recorded for {self.repo}#{self.pr}; reporting only new events")
|
|
536
|
+
self.baseline = False
|
|
537
|
+
time.sleep(self.interval)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def main() -> None:
|
|
541
|
+
parser = argparse.ArgumentParser(prog="pr-wait", description="Watch a GitHub PR and print one line per real event.")
|
|
542
|
+
parser.add_argument("pr", nargs="?", help="PR number or URL")
|
|
543
|
+
parser.add_argument("--repo", help="owner/name (default: from the URL or the current checkout)")
|
|
544
|
+
parser.add_argument("--until", choices=("green", "ready", "any-change"), default="ready")
|
|
545
|
+
parser.add_argument("--interval", type=int, default=20, help="seconds between polls (default 20, min 5)")
|
|
546
|
+
args = parser.parse_args()
|
|
547
|
+
repo, pr = parse_target(args.pr, args.repo)
|
|
548
|
+
_lock = lock(repo, pr)
|
|
549
|
+
Watcher(repo, pr, args.until, max(args.interval, 5)).run()
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
if __name__ == "__main__":
|
|
553
|
+
try:
|
|
554
|
+
main()
|
|
555
|
+
except KeyboardInterrupt:
|
|
556
|
+
sys.exit(130)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: file-pr
|
|
3
|
+
description: "起草简洁的 PR 标题和描述,供人工审核发布。"
|
|
4
|
+
---
|
|
5
|
+
<!-- Adapted from https://github.com/t3dotgg/tstack/tree/7de017b3fc9d50c03cf3ecb98fb71e0ca65a78bd/skills/file-pr; Copyright (c) 2026 Theo Browne; MIT, see LICENSE-tstack. Changes: Chinese prompts, Agent SDK delegation, bounded read tools, no automatic publication/merge/force-push. Original snapshot is fixtures/tstack in agent2cursor. -->
|
|
6
|
+
|
|
7
|
+
# Prepare a PR
|
|
8
|
+
|
|
9
|
+
先确认修改对应用户问题。标题解释结果,正文从问题和修复后的行为开始,再列验证证据和未验证项。以实际可验证的模型/Agent SDK 信息署名,不猜模型名。
|
|
10
|
+
|
|
11
|
+
这是草稿工具流程:默认不创建 PR、不 push、不更改已有 PR 描述。输出可复制的标题与正文,由用户发布。需要评论格式时加载 leaving-pr-comments。检查引文是否包含秘密或私人信息。
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: full-send
|
|
3
|
+
description: "Full send;完成本地修改、评审并准备 PR 草稿,在合并前停止。"
|
|
4
|
+
---
|
|
5
|
+
<!-- Adapted from https://github.com/t3dotgg/tstack/tree/7de017b3fc9d50c03cf3ecb98fb71e0ca65a78bd/skills/full-send; Copyright (c) 2026 Theo Browne; MIT, see LICENSE-tstack. Changes: Chinese prompts, Agent SDK delegation, bounded read tools, no automatic publication/merge/force-push. Original snapshot is fixtures/tstack in agent2cursor. -->
|
|
6
|
+
|
|
7
|
+
# Full Send — safe preparation
|
|
8
|
+
|
|
9
|
+
触发“full send”时,加载 sol-loop、file-pr、babysit-pr 和 leaving-pr-comments。目标是完成最小的本地修改,并准备可供人工发布的 PR。
|
|
10
|
+
|
|
11
|
+
1. 使用 read、grep、glob、ls 理解请求;只编辑属于本次任务的文件。
|
|
12
|
+
2. 通过 task 委派 reviewer 做最多三轮检查。没有测试执行器时,明确写“测试未运行”。
|
|
13
|
+
3. 用 file-pr 起草标题、问题、方案和验证证据;默认没有 Git push 或建 PR 工具,提交草稿供用户操作。
|
|
14
|
+
4. 已有 PR 时用 pr_wait 读取一次状态,之后由 GitHub 事件或用户继续触发;不自行轮询。
|
|
15
|
+
5. PR 准备好时停止。默认不合并,不强推,不发评论。
|
|
16
|
+
|
|
17
|
+
只有服务端操作员显式配置合并开关并批准本次工具调用,merge_pr 才能尝试合并已确认的最新 head。不要把“full send”文字理解为开启开关。
|
|
18
|
+
|
|
19
|
+
遇到阻塞时保留 PR,最终回复开头用中文提出具体问题。报告修改、评审、测试未覆盖项和人工下一步。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: leaving-pr-comments
|
|
3
|
+
description: "准备有来源说明的 GitHub 评论草稿;默认不发送。"
|
|
4
|
+
---
|
|
5
|
+
<!-- Adapted from https://github.com/t3dotgg/tstack/tree/7de017b3fc9d50c03cf3ecb98fb71e0ca65a78bd/skills/leaving-pr-comments; Copyright (c) 2026 Theo Browne; MIT, see LICENSE-tstack. Changes: Chinese prompts, Agent SDK delegation, bounded read tools, no automatic publication/merge/force-push. Original snapshot is fixtures/tstack in agent2cursor. -->
|
|
6
|
+
|
|
7
|
+
# Comment draft attribution
|
|
8
|
+
|
|
9
|
+
默认只起草。只有操作员另行实现并授权写工具后才能发布。草稿先使用以下 GitHub NOTE,正文与署名分开:
|
|
10
|
+
|
|
11
|
+
> [!NOTE]
|
|
12
|
+
> 🤖 **Agent SDK responding on behalf of <user>**
|
|
13
|
+
|
|
14
|
+
正文先给结论,引用实际代码或检查,区分事实与判断。若能验证模型公开名称,可以替换 Agent SDK。不要猜模型身份,不泄露秘密。不要给 PR 描述加评论署名块。
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sol-loop
|
|
3
|
+
description: "使用 reviewer 子代理进行有界代码评审;默认不声称使用 Sol。"
|
|
4
|
+
---
|
|
5
|
+
<!-- Adapted from https://github.com/t3dotgg/tstack/tree/7de017b3fc9d50c03cf3ecb98fb71e0ca65a78bd/skills/sol-loop; Copyright (c) 2026 Theo Browne; MIT, see LICENSE-tstack. Changes: Chinese prompts, Agent SDK delegation, bounded read tools, no automatic publication/merge/force-push. Original snapshot is fixtures/tstack in agent2cursor. -->
|
|
6
|
+
|
|
7
|
+
# Review loop
|
|
8
|
+
|
|
9
|
+
通过 task 调用 reviewer;给出范围、用户意图、相关代码和已有证据。最多三轮,后续只评审已改代码与未解决问题。只修复实际问题,不扩大范围。
|
|
10
|
+
|
|
11
|
+
reviewer 默认继承父模型,因此这是单模型角色评审,不是已验证的 GPT Sol。记录实际可观测的模型,无法观测时如实说明。没有回复、超时、预算耗尽都不是通过。不要自动推送或发评论。
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineTool } from "@grok/agent-sdk/tools";
|
|
2
|
+
import { parseGitHubPrContinuationKey } from "@grok/agent-sdk/channels/github";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { DEMO_REPOSITORY } from "../lib/settings.js";
|
|
5
|
+
import { mergeBlockReason } from "../lib/merge-policy.js";
|
|
6
|
+
export default defineTool({
|
|
7
|
+
description: "合并当前 GitHub 会话的 PR。默认关闭;需要操作员开关、人工批准、精确 head SHA 和 GitHub 合并门禁。",
|
|
8
|
+
inputSchema: z.object({ expectedHead: z.string().regex(/^[a-f0-9]{40}$/i) }),
|
|
9
|
+
effect: "write",
|
|
10
|
+
needsApproval: true,
|
|
11
|
+
dryRunResult: () => ({ merged: false, reason: "dry-run" }),
|
|
12
|
+
async execute({ expectedHead }, ctx) {
|
|
13
|
+
if (ctx.session.purpose === "eval" || process.env.AGENT2CURSOR_ALLOW_MERGE !== "1" || !ctx.approvedBy) return { merged: false, reason: "merge disabled or not approved" };
|
|
14
|
+
const ref = parseGitHubPrContinuationKey(ctx.session.continuationKey ?? "");
|
|
15
|
+
if (!ref || DEMO_REPOSITORY === "OWNER/REPO" || `${ref.owner}/${ref.repo}` !== DEMO_REPOSITORY) return { merged: false, reason: "configured GitHub session required" };
|
|
16
|
+
const octokit = await ctx.host.github.getOctokit();
|
|
17
|
+
const { data: pr } = await octokit.rest.pulls.get({ owner: ref.owner, repo: ref.repo, pull_number: ref.number });
|
|
18
|
+
const blocked = mergeBlockReason({ enabled: process.env.AGENT2CURSOR_ALLOW_MERGE, approved: Boolean(ctx.approvedBy), purpose: ctx.session.purpose, configuredRepository: DEMO_REPOSITORY, sessionRepository: `${ref.owner}/${ref.repo}`, currentHead: pr.head.sha, expectedHead, state: pr.state, draft: Boolean(pr.draft), mergeableState: pr.mergeable_state });
|
|
19
|
+
if (blocked) return { merged: false, reason: blocked };
|
|
20
|
+
const { data } = await octokit.rest.pulls.merge({ owner: ref.owner, repo: ref.repo, pull_number: ref.number, sha: expectedHead, merge_method: "squash" });
|
|
21
|
+
return { merged: data.merged, sha: data.sha, reason: data.merged ? "merged" : "GitHub declined merge" };
|
|
22
|
+
},
|
|
23
|
+
});
|