feat: PM-Agent also need to look at PR comments
From Gitea issue #116. Authored by the PM agent's worker; the merge gate is the approver. Refs #116. Agent: pm-agent (issue #116, mode revise)
This commit is contained in:
1 parent
2fdb95393c
commit
e8e9de8611
7 files changed
+471
-189
No files matched your search
+107
-72
@@ -267,9 +267,8 @@ class Config:
|
||||
self.pr_max_changed_files = env_int("PM_PR_MAX_CHANGED_FILES", 200)
|
||||
# PM_PLAN: discover, assemble and PRINT, ask no model, post nothing. The dry-run mode
|
||||
# for this path. PM_DRY_RUN implies it, because on the PR path the reply IS the
|
||||
# action under test *and* it is the continuation marker, so posting it would make a
|
||||
# dry run not dry. PM_PLAN also implies pr_mentions, since its whole purpose is to
|
||||
# look before the knob is turned on.
|
||||
# action under test, so posting it would make a dry run not dry. PM_PLAN also implies
|
||||
# pr_mentions, since its whole purpose is to look before the knob is turned on.
|
||||
self.pr_plan = env_bool("PM_PLAN", False)
|
||||
if self.pr_plan:
|
||||
self.pr_mentions = True
|
||||
@@ -442,14 +441,16 @@ class Gitea:
|
||||
WHY THIS AND NOT A SCAN. The operator asked for "a way to check for '@'s for the bot
|
||||
account instead of scouring the entire pull request list and associated threads"
|
||||
(issue #116). A mention makes Gitea subscribe the mentioned account to the thread and
|
||||
raise a notification, and — the part that matters for thread continuation — the
|
||||
subscription then raises one for EVERY later comment on that thread, with no second
|
||||
mention needed. So the endpoint answers both halves of the ask with one bounded call.
|
||||
raise a notification, so one bounded call names the pull requests worth reading.
|
||||
|
||||
It is a HINT, never the decision: this returns candidate pull-request indices, and
|
||||
the mention/continuation decision is made by re-reading that PR's comments. Nothing
|
||||
is marked read, so no state is consumed and a crash loses nothing (see
|
||||
plan_pr_replies for the idempotency record that does the real work).
|
||||
whether any of them holds an unanswered mention is decided by re-reading that PR's
|
||||
comments (plan_pr_replies). That split is why over-delivery here is harmless — the
|
||||
subscription keeps raising notifications for later comments on a pull request the bot
|
||||
was once mentioned on, and those now resolve to nothing, because an explicit mention
|
||||
is the only trigger. Nothing is marked read, so no state is consumed and a crash
|
||||
loses nothing (see plan_pr_replies for the idempotency record that does the real
|
||||
work).
|
||||
"""
|
||||
q = "/notifications?all=true&subject-type=pull"
|
||||
if since_epoch > 0:
|
||||
@@ -868,9 +869,28 @@ def last_activity(issue: dict, comments) -> tuple:
|
||||
#
|
||||
# A mention of the bot account in a pull request's conversation, or inside an inline review
|
||||
# thread, makes the PM read that pull request — its description, its changed files, its diff
|
||||
# and the thread being spoken to — and reply IN THAT THREAD. Once it has replied in a thread,
|
||||
# later comments in the same thread continue the conversation with no second mention, which
|
||||
# is what the operator asked for in issue #116 answer (4').
|
||||
# and the thread being spoken to — and reply IN THAT THREAD.
|
||||
#
|
||||
# AN EXPLICIT MENTION IS THE ONLY TRIGGER. There is no continuation: a comment that does not
|
||||
# name the bot is never actionable, however recently the bot was mentioned or replied nearby.
|
||||
# The operator settled this on issue #116 — "since there is no threads and the PR comments
|
||||
# are flat, then the pm-agent should be looking for explicit mentions rather than a single
|
||||
# mention and continued scrapping of comments within the PR in perpetuity" — withdrawing the
|
||||
# earlier "a thread that starts with a mention should continue" answer, which had been
|
||||
# premised on comments being hierarchical. So a mention is a DISCRETE, SELF-LIMITING request:
|
||||
# one `@pm-agent` -> one reply where it was written, and nothing else on the pull request is
|
||||
# picked up. That is applied uniformly to conversation comments, review bodies and inline
|
||||
# review comments; the inline ones are genuinely threaded in Gitea, but keeping continuation
|
||||
# for them alone would be a second rule the operator did not ask for.
|
||||
#
|
||||
# Two things that were open questions under the old rule are closed by construction rather
|
||||
# than by a new termination rule: there is no open-ended continuation left to terminate, and
|
||||
# an automation that comments on a pull request the bot was once mentioned on draws no reply.
|
||||
# What remains — and it is the whole residual — is that ANY non-bot author who writes the
|
||||
# literal handle gets one reply, because the operator's answer (3) is "anyone can talk and
|
||||
# have the agent work". A quoted mention counts as a mention: Gitea has no way to tell
|
||||
# `> @pm-agent what do you think?` pasted into a quote from a fresh one, so re-quoting an
|
||||
# earlier mention in a new comment costs one more reply.
|
||||
#
|
||||
# It may ONLY reply. The change-authoring half of that issue — revise an agent-owned branch,
|
||||
# or open a fresh branch and PR against `main` for somebody else's — is a separate concern
|
||||
@@ -898,8 +918,8 @@ def last_activity(issue: dict, comments) -> tuple:
|
||||
# SO THE FAILURE MODE CHOSEN, EXPLICITLY, IS: at-least-once MODEL CALL, at-most-once REPLY.
|
||||
# A mention is never silently dropped and a reply is never duplicated; the price is that a
|
||||
# crash in the wrong second costs one more decision's spend. That is the right way round for
|
||||
# this service — a duplicated reply is noise in the operator's thread AND sets the
|
||||
# continuation marker twice, while PM_PR_COOLDOWN_SECONDS already bounds the cost of a repeat.
|
||||
# this service — a duplicated reply is noise in the operator's thread, while
|
||||
# PM_PR_COOLDOWN_SECONDS already bounds the cost of a repeat.
|
||||
# The alternative (write a marker BEFORE asking the model) buys cheapness by making a crash
|
||||
# swallow the mention with nothing in the thread saying so, which is the failure this
|
||||
# repository keeps being burned by.
|
||||
@@ -910,20 +930,20 @@ def last_activity(issue: dict, comments) -> tuple:
|
||||
# run. A bug that let the bot's own replies count as triggers would be an unbounded loop that
|
||||
# any passer-by could light. The self-authored check is therefore the FIRST thing
|
||||
# plan_pr_replies does with each comment, it is structural (the bot's own comments can only
|
||||
# ever CLEAR work, never create it), and the fixtures exercise it.
|
||||
# ever CLEAR work, never create it), and the fixtures exercise it. Dropping continuation
|
||||
# narrows this surface further: a reply is now reachable only from a comment that spells the
|
||||
# handle out, so the bot's own output — which never writes `@<bot>` — is inert twice over.
|
||||
|
||||
MARKER_PREFIX = "pm-agent:replied-to="
|
||||
MARKER_RE = re.compile(r"<!--\s*" + re.escape(MARKER_PREFIX) + r"([^\s>]*)\s*-->")
|
||||
|
||||
# What "the same thread" means here. Inline review comments have a native thread identity in
|
||||
# Gitea: comments on the same (path, position) are one conversation in its UI. PR conversation
|
||||
# comments are a FLAT LIST with no thread identity at all, so "the same thread" needs a
|
||||
# policy — and this repository already has one, which is why nothing new was invented. The
|
||||
# issue path's trigger is "act iff the newest thing in this thread was not written by me",
|
||||
# with the whole flat comment list treated as one conversation (see last_activity and the file
|
||||
# header). The same reading is applied here: a pull request's conversation is ONE thread, and
|
||||
# once the PM has replied in it, later non-bot comments in it continue the conversation.
|
||||
# Flagged on issue #116 for the operator to confirm, because it is a policy and not a fact.
|
||||
# The thread identity, which is now about WHERE A REPLY GOES and nothing else. It stopped
|
||||
# being part of the trigger when continuation was dropped: no comment is actionable because
|
||||
# of its neighbours, so no policy is needed for what counts as "the same thread" — the
|
||||
# operator's point that PR conversation comments are flat is precisely why. What is still
|
||||
# needed is a destination, and the two destinations differ: a mention in the conversation (or
|
||||
# in a review's own body, which Gitea renders in the conversation) is answered with a
|
||||
# conversation comment, while a mention on a (path, position) is answered inline there.
|
||||
CONVERSATION_THREAD = "conversation"
|
||||
|
||||
|
||||
@@ -1007,10 +1027,11 @@ def normalize_pr_entries(comments, reviews, review_comments_by_review) -> list:
|
||||
These carry a thread identity of their own.
|
||||
Order is conversation-then-reviews rather than a merge sorted by timestamp, on purpose:
|
||||
the repository's own rule is that lexicographic comparison of Gitea's RFC 3339 strings is
|
||||
only correct while every offset matches (see last_activity), and getting a global order
|
||||
wrong here would mis-decide continuation. Each resource is returned by Gitea in creation
|
||||
order, and continuation is only ever judged WITHIN one thread, so per-resource order is
|
||||
all this needs.
|
||||
only correct while every offset matches (see last_activity). Nothing in the trigger reads
|
||||
across comments now that an explicit mention is the only thing that fires, so order is
|
||||
used for two things only — rendering the thread in the payload, and picking which plan is
|
||||
taken first when several threads hold mentions — and Gitea returns each resource in
|
||||
creation order, which is all either needs.
|
||||
"""
|
||||
out = []
|
||||
for c in comments or []:
|
||||
@@ -1050,15 +1071,24 @@ def normalize_pr_entries(comments, reviews, review_comments_by_review) -> list:
|
||||
def plan_pr_replies(bot_login: str, entries: list) -> list:
|
||||
"""The whole trigger decision, as a PURE function over normalized comments.
|
||||
|
||||
THE RULE, in one line: reply to a comment iff it is not ours, it explicitly mentions us,
|
||||
and no reply of ours already says it was answered. Nothing else is actionable — a comment
|
||||
with no mention produces no reply and no model call no matter what sits above it, which is
|
||||
what the operator asked for on issue #116 once it was established that PR comments are
|
||||
flat rather than hierarchical.
|
||||
|
||||
Pure because this is the part that must be provable offline: it holds the loop guard, the
|
||||
idempotency skip and the continuation policy, and none of the three can be exercised
|
||||
against a live Gitea without first risking the behaviour they prevent. The fixtures in
|
||||
mention match and the idempotency skip, and none of the three can be exercised against a
|
||||
live Gitea without first risking the behaviour they prevent. The fixtures in
|
||||
infra/agent/testdata/pr-mention-fixtures.json drive it.
|
||||
|
||||
Returns one plan per thread that needs a reply, NEWEST-ANCHORED FIRST:
|
||||
Returns one plan per thread holding an unanswered mention, NEWEST-ANCHORED FIRST:
|
||||
{thread, reason, keys, anchor, anchor_index, triggers}
|
||||
`keys` is every comment this one reply answers — a burst of three comments in a thread
|
||||
gets ONE reply covering all three, not three replies.
|
||||
`reason` is always "mention" — the field is kept because it is what the log line and
|
||||
PM_PLAN print as "why you are running", and a second value would mean a second trigger.
|
||||
`keys` is every unanswered MENTION in that thread, so somebody who tags the bot twice in
|
||||
one conversation before it answers gets ONE reply covering both, not two. Comments that
|
||||
merely sit between them are not answered and not keyed.
|
||||
"""
|
||||
mre = mention_re(bot_login)
|
||||
|
||||
@@ -1076,51 +1106,36 @@ def plan_pr_replies(bot_login: str, entries: list) -> list:
|
||||
|
||||
plans = []
|
||||
for thread, items in by_thread.items():
|
||||
# "Engaged" = this thread is a conversation the PM is part of, so later comments in
|
||||
# it need no second mention. ARMED BY THE MENTION, not by the PM's reply — the
|
||||
# operator's wording on issue #116 is "a thread that STARTS WITH A MENTION should
|
||||
# continue the conversation without subsequent mentions in follow-up questions as
|
||||
# long as they remain in the same thread". The difference is not academic: somebody
|
||||
# who types three comments in a row and tags the bot only in the first would, under
|
||||
# the weaker reading, have the last two silently ignored until the PM happened to
|
||||
# reply. Fixture (i) is that case.
|
||||
#
|
||||
# A marker in one of OUR OWN comments also arms it, which covers a thread whose
|
||||
# arming mention has since been edited away or scrolled out of what was fetched.
|
||||
engaged = False
|
||||
triggers = []
|
||||
trigger_indices = []
|
||||
for i, e in items:
|
||||
# ---- THE FIRST CHECK, AND THE ONE THAT MUST NEVER MOVE ----
|
||||
# Our own words can clear work but can never create it. Everything downstream —
|
||||
# the mention match AND the arming of continuation — is unreachable for a
|
||||
# bot-authored comment, so a reply of ours that happens to contain our own
|
||||
# handle cannot start a run. Fixture (c) is that case, and with no author gate
|
||||
# on this trigger it is the whole loop guard.
|
||||
# Our own words can clear work but can never create it. The mention match below
|
||||
# is unreachable for a bot-authored comment, so a reply of ours that happens to
|
||||
# contain our own handle cannot start a run. Fixture (c) is that case, and with
|
||||
# no author gate on this trigger it is the whole loop guard.
|
||||
if e["author"] == bot_login:
|
||||
if markers_in(e["body"]):
|
||||
engaged = True
|
||||
continue
|
||||
mentioned = bool(mre.search(e["body"]))
|
||||
if mentioned:
|
||||
engaged = True
|
||||
if e["key"] in handled:
|
||||
continue
|
||||
if mentioned or engaged:
|
||||
triggers.append(e)
|
||||
trigger_indices.append(i)
|
||||
# ---- AND THE ONLY WAY IN ----
|
||||
# No `engaged`/armed state, by design: whatever came before this comment in the
|
||||
# thread cannot make it actionable. See the section header for the operator's
|
||||
# wording that withdrew continuation.
|
||||
if not mre.search(e["body"]):
|
||||
continue
|
||||
triggers.append(e)
|
||||
trigger_indices.append(i)
|
||||
if not triggers:
|
||||
continue
|
||||
anchor_index = trigger_indices[-1]
|
||||
plans.append({
|
||||
"thread": thread,
|
||||
"reason": "mention" if any(mre.search(t["body"]) for t in triggers)
|
||||
else "continuation",
|
||||
"reason": "mention",
|
||||
"keys": [t["key"] for t in triggers],
|
||||
"triggers": triggers,
|
||||
# Reply to the NEWEST triggering comment: it is the one that asked.
|
||||
# Reply to the NEWEST unanswered mention: it is the one that asked most recently.
|
||||
"anchor": triggers[-1],
|
||||
"anchor_index": anchor_index,
|
||||
"anchor_index": trigger_indices[-1],
|
||||
})
|
||||
plans.sort(key=lambda p: p["anchor_index"], reverse=True)
|
||||
return plans
|
||||
@@ -1186,14 +1201,15 @@ def render_pr_thread(cfg: Config, pr: dict, files, diff: str, plan: dict,
|
||||
f"mergeable: {pr.get('mergeable')}",
|
||||
f"draft: {pr.get('draft')}",
|
||||
"",
|
||||
f"why you are running: **{plan['reason']}** "
|
||||
+ ("(you were mentioned by name)" if plan["reason"] == "mention"
|
||||
else "(you have already replied in this thread, so follow-ups continue without "
|
||||
"a new mention)"),
|
||||
f"why you are running: **{plan['reason']}** (you were mentioned by name — that is "
|
||||
"the ONLY thing that starts a run on a pull request, so somebody asked you this "
|
||||
"deliberately)",
|
||||
f"the thread you are replying in: "
|
||||
+ (f"the inline review thread on `{anchor['path']}` (diff position "
|
||||
f"{anchor['line']})" if is_inline else "the pull request's conversation"),
|
||||
f"comments this reply answers: {', '.join(plan['keys'])}",
|
||||
f"the mention(s) this reply answers: {', '.join(plan['keys'])}",
|
||||
"everything else below is CONTEXT, not a question put to you: comments that do not "
|
||||
"mention you are shown so you can read the discussion, and are not yours to answer.",
|
||||
"",
|
||||
"## What you may decide here",
|
||||
"",
|
||||
@@ -1480,7 +1496,8 @@ class Dispatcher:
|
||||
if cfg.dry_run:
|
||||
warn("PM_DRY_RUN is set: decisions are posted as comments, no worker Jobs are created")
|
||||
if cfg.pr_mentions:
|
||||
log(f"pull-request mentions: ON — @{cfg.bot_login} on a pull request triages it; "
|
||||
log(f"pull-request mentions: ON — an explicit @{cfg.bot_login} on a pull request "
|
||||
"triages it, and nothing else does; "
|
||||
f"discovery {cfg.pr_discovery}, cooldown {cfg.pr_cooldown}s, "
|
||||
f"max {cfg.pr_max_replies} reply/replies per cycle")
|
||||
if cfg.pr_plan:
|
||||
@@ -1900,9 +1917,10 @@ def selftest_pr_mentions(check) -> None:
|
||||
"""The pull-request-mention trigger, driven by committed Gitea-shaped fixtures.
|
||||
|
||||
Everything asserted here is a control rather than a nicety: the self-authored filter is
|
||||
the loop guard for a trigger with no author gate, the marker scan is the idempotency
|
||||
record, and the continuation rule is the policy the operator asked for. None of the three
|
||||
can be exercised safely against a live Gitea for the first time.
|
||||
the loop guard for a trigger with no author gate, the mention pattern is now the ONLY
|
||||
way in (so a false positive is a run nobody asked for and a false negative is a dropped
|
||||
question), and the marker scan is the idempotency record. None of the three can be
|
||||
exercised safely against a live Gitea for the first time.
|
||||
"""
|
||||
# -- the mention pattern, before any fixture ------------------------------
|
||||
mre = mention_re("pm-agent")
|
||||
@@ -1968,14 +1986,17 @@ def selftest_pr_mentions(check) -> None:
|
||||
|
||||
# -- the trigger decision, case by case ------------------------------------
|
||||
bot = data["bot_login"]
|
||||
mre = mention_re(bot) # re-bound from the fixture, so the two cannot drift apart
|
||||
cases = data.get("cases") or []
|
||||
check(len(cases) >= 6, f"fixtures cover at least the six required cases ({len(cases)})")
|
||||
all_plans = []
|
||||
for case in cases:
|
||||
entries = normalize_pr_entries(
|
||||
case.get("comments"),
|
||||
case.get("reviews"),
|
||||
{int(k): v for k, v in (case.get("review_comments") or {}).items()})
|
||||
plans = plan_pr_replies(bot, entries)
|
||||
all_plans += plans
|
||||
got = [{"thread": p["thread"], "reason": p["reason"], "keys": p["keys"]}
|
||||
for p in plans]
|
||||
want = case["expect"]["threads"]
|
||||
@@ -1984,6 +2005,20 @@ def selftest_pr_mentions(check) -> None:
|
||||
print(f" want {json.dumps(want)}")
|
||||
print(f" got {json.dumps(got)}")
|
||||
|
||||
# -- the trigger property itself, over EVERY fixture ----------------------
|
||||
# Asserted across all cases rather than inside each one: "an explicit mention is the only
|
||||
# trigger" is the operator's requirement, and a single fixture quietly expecting
|
||||
# something else is exactly how a continuation rule would creep back in.
|
||||
check(bool(all_plans), "fixtures: at least one case does produce a reply")
|
||||
check(all(p["reason"] == "mention" for p in all_plans),
|
||||
"trigger: every plan is a mention — there is no other reason a run can happen")
|
||||
check(all(mre.search(t["body"]) for p in all_plans for t in p["triggers"]),
|
||||
"trigger: every comment answered spells out the handle itself")
|
||||
check(all(t["author"] != bot for p in all_plans for t in p["triggers"]),
|
||||
"trigger: no bot-authored comment is ever answered")
|
||||
check(all(t["key"] in p["keys"] for p in all_plans for t in p["triggers"]),
|
||||
"trigger: the marker will name every comment the reply answers")
|
||||
|
||||
# -- the assembled payload, on a hostile pull request ----------------------
|
||||
# The point of this one: a PR body and a diff are attacker-controlled text (anyone who
|
||||
# can push a branch can write both), so neither may reach the model anywhere but inside a
|
||||
|
||||
Reference in new issue
Block a user