Trigger the agent when a checkbox is toggled in a Forgejo comment or description #229
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#229
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
When the agent posts a markdown checklist on a Forgejo issue or pull request, a user with
write access ticking one of those boxes must wake the agent. The same must work when the
edited checklist sits in someone else's comment, in an inline review comment, or in the
issue/PR description.
Today this does not work, blocked at three independent layers:
apps/server/src/forgejo/webhook.ts:160-168):issue_comment/pull_request_commentwithaction: "edited"is classified, but the author is read fromcomment.user.loginand anything authored by the agent is ignored as"self-authored".An edit to the agent's own comment therefore never becomes a trigger row.
apps/server/src/forgejo/dispatcher.ts:613-622): Forgejo puts the editorin
sender, and a row whosesenderdiffers fromrow.commentAuthoris dropped with"edited by someone other than its author". That is exactly "user ticks a box in theagent's (or a third party's) comment".
apps/server/src/forgejo/conversation-detect.ts:175-182): foraction: "edited",detectAddressedreturns onlyedited-mention— an edit that adds an@mentionwhich was absent before. A checkbox toggle adds no mention, so the row isdropped as
"not addressed to the agent".Issue/PR description edits are rejected outright:
webhook.ts:137-146has noeditedbranch and
webhook.test.ts:137pins"unsupported action edited".Forgejo-side facts, grounded in
services/webhook/notifier.goandweb_src/js/markup/tasklist.json theforgejobranch:UpdateCommenthas no comment-type guard, so editing an inline review comment firesthe same
issue_comment/pull_request_commentevent withaction: "edited",changes.body.from, andissue= the PR. Inline comments need no new event handling.IssueChangeContentfiresissues(non-PR) orpull_request(PR) withaction: "edited"and
changes.body.from.IssueChangeTitlealso firesaction: "edited", withchanges.titleand nochanges.body— so a missingchanges.body.frommust be rejected.URL, one request per click, flipping exactly one character. Clean positional pairing holds
in practice; messy diffs only arise from hand-edited markdown.
data-can-edit=true(poster or write access), soForgejo already enforces most of the permission gate; the deck's check is defence in depth.
End state: a checkbox state change in a comment, inline review comment, or description, made
by the repo owner or a user with write/admin access, on an artifact the agent has taken part
in, wakes the agent with a neutral seed naming which boxes flipped. Bursts of toggles
collapse into one turn.
Approach
1. Export the prose-line splitter
In
apps/server/src/forgejo/conversation-detect.ts, changeforEachProseLine(line 80) frommodule-private to
export. Signature is unchanged:It emits every line outside fenced code blocks, blank lines and
>quote lines included,which is what block extraction below needs. No other change to that function.
2. New pure module: checkbox parsing and diffing
Create
apps/server/src/forgejo/checkbox-diff.ts. Nothing in the repo parses markdown tasklists (searched for
\[[ xX]\],checkbox,task.?listacrossapps/server/srcandpackages), so this is new code. Pure: no DB, no network, mirroringconversation-detect.ts.parseTaskItems(markdown: string): TaskItem[]— runforEachProseLine; skip linesmatching
/^\s*>/(a quoted checklist is not the author's); for each remaining line match/^\s*[-*+] \[([ xX])\][ \t]?(.*)$/, pushing{ label: m[2].trim(), checked: m[1] !== " " }.Order is document order. Indentation is ignored, so nested sub-items are ordinary items.
checklistBlocks(markdown: string): string— runforEachProseLinecollecting lines intoan array. Walk it: a block starts at the first line matching the task-item regex and runs up
to (excluding) the first line matching
/^\s*$/or the end of the array; scanning thenresumes after that blank line. Join the blocks' lines with
\n, and separate blocks from oneanother with a single blank line. Returns
""when there is no task line. This keeps a longcomment from bloating the turn: only the checklist travels, not the whole body.
firstWord(label: string): string(module-private) — lowercase, take the firstwhitespace-delimited token, strip characters not matching
/[\p{L}\p{N}]/ufrom both ends.""for an empty label.diffCheckboxes(before: string, after: string): CheckboxChange | null—a = parseTaskItems(before),b = parseTaskItems(after).`${firstWord(item.label)}\u0000${item.checked ? "1" : "0"}`and compare the two withJSON.stringify. Equal → returnnull(no checkbox state change). This makes a purereorder, and any prose edit that leaves every item's first word and state alone, a non-event,
while a tick combined with an unrelated reorder, and a simultaneous tick + untick, both
still register.
a.length === b.lengthandfirstWord(a[i].label) === firstWord(b[i].label)for every
i. Thenflips=b[i](as{ label, checked }) for everyiwherea[i].checked !== b[i].checked. Otherwiseflips = null— the dispatcher does not tryharder, it hands the agent both checklists and lets it work out the difference.
{ flips, beforeBlocks: checklistBlocks(before), afterBlocks: checklistBlocks(after) }.3. Accept edits that the receiver currently discards
All in
apps/server/src/forgejo/webhook.ts.Comment edits — in the
issue_comment/pull_request_commentbranch(
classifyForgejoDelivery, lines 150-187), the author/self check becomes action-dependent:action === "created": unchanged.commentAuthoriscomment.user.login; a commentauthored by the agent is ignored as
"self-authored".action === "edited": the acting principal is the editor. Readsender.login; malformed"missing sender.login"when absent or not a string. Ignore withreason
"self-edited"when it equalsagentUsernamecase-insensitively — this is the loopguard, since the agent rewrites its own checklists.
comment.user.loginis still required(malformed otherwise) but no longer gates acceptance. The accepted trigger carries
commentAuthor: sender.login.Description edits — add, before the
issuesbranch's finalignore, and in thepull_requestfamily's action switch, anaction === "edited"case calling a newclassifyDescriptionEdit(eventType, payload, agentUsername, isPull):{ kind: "ignore", reason: "no body change" }unlesspayload.changes.body.fromis a string. This is what separates a content edit from atitle-only edit, which Forgejo also sends as
edited.sender.loginrequired (malformed"missing sender.login"); ignored as"self-edited"when it is the agent.
repository.full_nameand the number validated exactly as the comment branch does(
numberfromissue.numberforissues, frompull_request.numberornumberforpull_request).{ event: eventType, action: "edited", repo, number, isPull, commentAuthor: sender.login }.Dedupe — generalise
dedupeKey(line 478) to take the edited subject instead of only acomment:
Body unchanged (it already reads
updated_atandbodyoff the value). Comment branches passpayload.commentwith the real comment id; description edits passpayload.issue(orpayload.pull_request) withcommentId0. Successive toggles differ inbody, so eachtoggle gets its own key even inside one second.
Protocol: no change.
packages/protocol/src/index.ts:2168-2210already hasissues,pull_request,issue_comment,pull_request_commentin theeventunion andeditedinthe
actionunion. Extend thecommentAuthordoc comment (line 2211-2217) to say that foreditedit is the editor, not the author.Existing test to change:
apps/server/src/forgejo/webhook.test.ts:137assertsissues+editedyields{ kind: "ignore", reason: "unsupported action edited" }. Thatbehaviour is being replaced; rewrite the case to assert the new verdicts (accepted with
changes.body.frompresent;"no body change"without it).4. New trigger reason and detection rule
In
apps/server/src/forgejo/conversation-detect.ts:"checkbox-change"to theTriggerReasonunion (line 53) and to theCONVERSATION_REASONSset (line 62) — membership there is what lets the turn run in aworkspace-less session (
dispatcher.ts:667).REASON_TEXT(line 258):"checkbox-change": "a checkbox was ticked or unticked".action === "edited"branch ofdetectAddressed(lines 175-182). Two newoptional input fields:
New branch body, replacing the current one:
The participation gate: the agent wrote the edited text, or it appears anywhere in the
conversation. Because the dispatcher prepends
openingEntry(dispatcher.ts:1953), anartifact the agent opened satisfies it too. Checkbox detection outranks
edited-mentionwhen an edit does both; a failed participation check still falls through to the mention rule,
so no existing behaviour is lost.
5. Seed builder for a checkbox turn
New export in
conversation-detect.ts, next tobuildConversationSeed. A dedicated builderrather than overloading
buildConversationSeed, because the turn has a different shape(editor, flips, two checklists) and may target a description, which has no
payload.comment.Lines, in order:
Forgejo checkbox change on <repo> <PR|issue> #<number>: "<issue.title ?? "?">"— and fortarget: "description",… in the description.Labels: … · Comments: …, copied verbatim frombuildConversationSeed(lines 331-332).Trigger: a checkbox was ticked or unticked(fromREASON_TEXT).Edited by @<editor>— plusin a comment by @<author>whenauthoris non-null anddiffers from
editor....orientationLines(t.orientation, { isPull: t.isPull, number: t.number }).change.flips !== nulland non-empty:Changed items:followed by one- [x] <label>/- [ ] <label>line per flip, reflecting the state after the edit.When
change.flips === null(or empty):The checklist could not be matched up reliably. Before and after follow.then a fencedchange.beforeBlockslabelledBefore:and afenced
change.afterBlockslabelledAfter:, usingfenceFor(line 416) on each so thecontent cannot close its own fence.
change.afterBlockslabelledChecklist now:(theflips !== nullpath only; thenullpath already showed it).A checkbox changed; nothing else is implied. Read the conversation and decide from its content what this asks of you — it may mean the item was selected for you to act on, or that someone reports it done, or neither. If it is not asking anything of you, end the turn without posting.6. Dispatcher: detection wiring
In
apps/server/src/forgejo/dispatcher.ts.Add to
Job(line 341):Initialise all three to
nullwherever aJobis constructed.Rewrite
detect(lines 1928-1955). The body of an edit is no longer onlypayload.comment?.body, because a description edit has none:bodiesbecomes[editedBody];previousBodystayspayload.changes?.body?.from ?? null,but for a settled burst use
job.checkboxBefore ?? fromandjob.checkboxAfter ?? editedBody(set in step 7).
action === "edited": computejob.checkbox = diffCheckboxes(before, after)whenbefore !== null. Ifjob.checkbox !== null, fetch the conversation(
this.fetchConversation(job), propagating its"failed"/"dropped"results) and calldetectAddressedwithconversation: [...openingEntry(...), ...conversation],checkboxChanged: true,editedAuthor. Ifjob.checkbox === null, keep today's fast path:detectAddressed({ ...input, conversation: null })and return its result without fetching.createdbehaviour is untouched.7. Dispatcher: 15-second settle window
Bursts: each click is a separate HTTP request and a separate trigger row, so a user ticking
several boxes would otherwise get one agent turn per tick. The dispatcher already has the
mechanism —
deferred(line 388) keeps a rowpendingand blocks its artifact inpump(line 452), and
drainreturns on"deferred"(lines 474-478) so later rows stay behind it.Only a timed release is missing;
releaseIntakeSlot(line 2690) is the model for the release.Add near the other constants (around line 188):
Add a field alongside
deferred:Clear every timer in the dispatcher's
dispose/teardown path (wheredisposedis set), so ashutdown leaves no pending handle.
In
gate(row)(line 501), before any API work, whenrow.action === "edited"and thepayload's
changes.body.fromis a string:diffCheckboxes(from, body). Null → skip the settlewindow entirely and continue gating as a normal (possibly
edited-mention) edit, so aplain text edit is never delayed.
listPendingForgejoTriggers(): rows with the samerepo(case-insensitive) andnumber,action === "edited", the same comment key — thepayload's
comment.id, or0for a description edit — and a non-nulldiffCheckboxesoftheir own. Keep queue order (the query already returns oldest first) and include
row.newestbe the last sibling. IfDate.now() - Date.parse(newest.receivedAt) < CHECKBOX_SETTLE_MS,schedule a
setTimeoutfor the remaining milliseconds that deletesrow.idfromsettleTimers,deferredandgated, then callsthis.pump(); store it insettleTimers(replacing and clearing any existing timer for that id) and return
"deferred".drainalways picks the oldest ungated pending row, sorowis the first sibling. Setjob.checkboxBeforeto the first sibling'schanges.body.fromandjob.checkboxAfterto the newest sibling's body (comment.body,else
issue.body, elsepull_request.body). For every sibling other thanrow:finishForgejoTrigger(id, "dropped", \coalesced into ${row.id}`)andthis.gated.delete(id). Then continue gatingrow` normally.Everything in step 4 is read from
payload_jsonin the database at gate time, so a crashinside the window loses nothing: the rows are still
pendingand the next boot's drainrecomputes the same result.
8. Dispatcher: authorisation for a checkbox trigger
Two edits in
gate, both in the block at lines 613-651.Replace the sender/author guard (lines 613-622). It currently drops every edit whose
senderdiffers fromrow.commentAuthor; sincecommentAuthoris now the editor foreditedrows, that comparison would never fire again and would silently open theedited-mentionpath to third-party editors. The rule becomes explicit:Non-
editedrows keep passing freely, exactly as the oldsender === commentAuthortest letthem (for those rows
commentAuthoris the sender).Tighten the permission gate (lines 636-651). The editor must be the repo owner or hold
write/admin; the issue/PR-opener shortcut and the
readfallback must not apply, so that anoutside contributor who opened an issue cannot drive the agent by ticking boxes in it:
and inside the
perm.data !== "write" && perm.data !== "admin"branch, require!checkbox && perm.data === "read" && (await this.readOnlyAllowed(job, row, reason))beforetolerating a
readprincipal.row.commentAuthoris already the editor, socheckPermission(row.repo, row.commentAuthor)asks about the right user with no further change.9. Dispatcher: seed selection
At the seed
if/elsechain (lines 1150-1172), add a branch before the finalelse:ReadyReasonalready includesTriggerReason(line 277), so"checkbox-change"needs nounion change there.
Critical files & anchors
apps/server/src/forgejo/webhook.ts—classifyForgejoDeliverylines 120-187 (commentbranch and the
issues/pull_requestaction switches) anddedupeKeyline 478. Theheader doc comment lists the Forgejo facts the module relies on; add the
edited/changes.body.from/ title-edit facts there.apps/server/src/forgejo/dispatcher.ts—gateline 501 (settle window, sender guard,permission gate),
detectline 1928, seed chain line 1150,Jobline 341,deferredline 388,
releaseIntakeSlotline 2690 as the model for the timed release.apps/server/src/forgejo/conversation-detect.ts—forEachProseLineline 80 (export),TriggerReasonline 53,CONVERSATION_REASONSline 62,REASON_TEXTline 258,detectAddressedline 167,buildConversationSeedline 311 as the shape to mirror,fenceForline 416.apps/server/src/forgejo/webhook.test.ts— line 137 pins the old "unsupported actionedited" verdict and must be rewritten.
apps/server/src/forgejo/dispatcher.test.ts—trigger(...)helper around line 500 buildsrows for the harness; reuse it for the end-to-end checks.
Verification
Working directory
apps/serverfor every command.apps/server/src/forgejo/checkbox-diff.test.ts, run withbun test src/forgejo/checkbox-diff.test.ts. Cases, each an explicit input → expected output:diffCheckboxes("- [ ] alpha\n- [ ] beta", "- [x] alpha\n- [ ] beta")→flipsequal to[{ label: "alpha", checked: true }].[{ label: "alpha", checked: false }]."- [x] a\n- [ ] b"→"- [ ] b\n- [x] a"→null.null.flips === null.flips === null, withbeforeBlocksandafterBlockscarrying only the checklist lines.>quote →null.checklistBlockson a body with prose, a checklist, a blank line and more prose returnsonly the checklist lines.
src/forgejo/webhook.test.ts, runbun test src/forgejo/webhook.test.ts:an
issue_commenteditedpayload whosecomment.user.loginis the agent and whosesender.loginisaliceis accepted withcommentAuthor: "alice"; the same withsender.loginequal to the agent is ignored with reason"self-edited"; anissueseditedpayload withchanges.body.fromis accepted, and without it is ignored with"no body change".src/forgejo/conversation-detect.test.ts:detectAddressedwithaction: "edited",checkboxChanged: trueand a conversation containing an agent entryreturns
"checkbox-change"; with a conversation holding no agent entry and aneditedAuthorthat is not the agent it returnsnull; withcheckboxChanged: falseand anewly added mention it still returns
"edited-mention".src/forgejo/dispatcher.test.tsusing itsexisting harness, run
bun test src/forgejo/dispatcher.test.ts:issue_commenteditedrow on an issue where the agent has a comment, whose payloadticks one box and whose
senderis the repo owner, starts a session whose seed containsTrigger: a checkbox was ticked or untickedand the line- [x] alpha. This is theheadline new behaviour and the check that proves the feature.
senderwhosecheckPermissionreturns"read"is dropped with"author lacks write access (read)", even though that user opened the issue.editedrow that adds a mention but changes no checkbox still yields"edited-mention", and one edited by a non-author with no checkbox change is stilldropped with
"edited by someone other than its author"— the two regression checks forthe rewritten guard.
editedrows for the same comment, the newer received less thanCHECKBOX_SETTLE_MSago, leave the artifact deferred; advancing past the window runs oneturn and drops the other row with a reason starting
"coalesced into ". Use theharness's existing clock/timer control if it has one; otherwise inject
CHECKBOX_SETTLE_MSthrough the dispatcher's deps so the test can set it to a fewmilliseconds.
bun test(full server suite) andbun run typecheckfrom the repository root.Assumptions & contingencies
families. The receiver classifies
issuesandpull_requestdeliveries today, so thesubscription covers them; but whether this deployment's hook has every needed box ticked
is
unverified — confirm first, since the hook configuration lives in Forgejo, not in thisrepo. If
issuesediteddeliveries never arrive after step 3 ships, the hook's eventselection is the cause, not the code; check it in Forgejo's repository webhook settings.
changes.body.fromwith the full previous body, not a truncated one. Thedispatcher already reads this field for
edited-mention(dispatcher.ts:1930) and thenotifier passes
oldContentwhole. If a deployment is found to truncate it, fall back toflips: nullwheneverbeforeis shorter than the common prefix ofaftersuggests, andlet the agent compare the two checklists.
splitting into several turns), raise
CHECKBOX_SETTLE_MS; the constant is the only thing tochange. Do not make it configurable per repository without a new decision.
agent checklist can be used both for "pick the items you want me to do" and for "I did
these". If turns prove inconsistent, the fix is a convention in the agent's own checklist
wording, not in the seed.