Repository navigation
Always include the github url in the Discord notification #6
Description
Activity
Plan
Always include the GitHub URL in Discord notifications (issue #6)
Make every run-scoped Discord embed carry a clickable GitHub link — the issue URL on the embed title plus a dedicated
Issuefield — and give the two notifications that currently have no GitHub context at all (RunCanceled,LabelUpdateFailed) a properRunRef.(Note: the plan file could not be written —
/home/node1/.claude/plansis on a read-only filesystem. The plan is below.)Context
internal/discord/notifier.gorenders every notification as a Discord embed. Today the link only appears whereRunRef.description()is used verbatim (RunClaimed,ClaudeFinished,VerifyResult), and even there it is buried in the description. The notifications that matter most when something goes wrong lose it:RunFailed,RunAbandoned,RunDeferredoverwriteDescriptionwith the cause/reason text, so the issue link disappears — only the plain-textowner/name#42in the title survives.PlanPostedsetsDescriptionto "Replyimplementon the issue…" — an instruction to go to the issue, with no link to it.PROpenedshows the PR URL but drops the issue link.RunCanceled(runID string)has no repo/issue at all.LabelUpdateFailed(repo, issue, runID, …)knows the repo and issue but never builds a URL.
The outcome we want: from any run-scoped Discord message, one click reaches the issue (and, for a PR notification, the PR).
Approach
1.
internal/discord/notifier.go— link plumbing-
Add
URL string \json:"url,omitempty"`to theembedstruct (notifier.go:82). Discord rendersembed.url` as a hyperlink on the title, which is the cheapest "always clickable" affordance. -
Add two small methods next to the existing
RunRef.title/description/fields(notifier.go:166-188):func (r RunRef) issueURL() string— returnsr.URLwhen set; otherwise deriveshttps://github.com/<Repo>/issues/<Issue>whenRepo != ""andIssue > 0; otherwise"".func (r RunRef) describe(body string) string— returnsr.description()whenbodyis empty,bodywhen there is no description, anddescription() + "\n" + bodyotherwise. This is what lets the failure/plan notifications keep both their message and the linked title.
-
Add a single choke point that every run-scoped post goes through:
func (n *Notifier) postRun(r RunRef, e embed) { if u := r.issueURL(); u != "" { if e.URL == "" { e.URL = u // title becomes a link to the issue } e.Fields = append(e.Fields, embedField{ Name: "Issue", Value: fmt.Sprintf("[%s#%d](%s)", r.Repo, r.Issue, u), }) } n.post(e) }
Deriving the URL rather than requiring it means a
RunRefbuilt from the store (which has no issue-URL column) still links correctly.
2. Route every run-scoped method through
postRunChange
n.post(embed{…})→n.postRun(r, embed{…})inRunClaimed,ClaudeFinished,VerifyResult,PROpened,PlanPosted,RunFailed,RunAbandoned,RunDeferred, plus the two reworked below. Additionally:RunFailed/RunAbandoned/RunDeferred:Description: r.describe(truncate(cause, 500)), so the linked issue title is back above the cause.PlanPosted:Description: r.describe("Reply \implement` on the issue to start the change.")`.PROpened: setURL: prURLon the embed explicitly (title links to the PR;postRunleaves a non-emptyURLalone),Description: r.describe(prURL), and add a{Name: "Pull request", Value: prURL}field. TheIssuefield added bypostRunthen gives both links in one message.
3.
RunCanceled— give it aRunRef- Signature becomes
func (n *Notifier) RunCanceled(r RunRef). Title:r.title("Run cancelled by operator")whenr.Repo != "", else the current plain"Run cancelled by operator"(a run row that can't be read must still produce a notification, and must not render as#0). Keep the run ID viar.fields(). - Call site
internal/server/server.go:264(cancelRun): afters.ctrl.Cancel(id)succeeds, look the run up with the existings.store.GetRun(c.Context(), id)(internal/store/store.go:538, returnsRepo,Issue,Attempt). On success postdiscord.RunRef{Repo: run.Repo, Issue: run.Issue, RunID: id, Attempt: run.Attempt}; on error log and postdiscord.RunRef{RunID: id}. The lookup must never change the HTTP result.
4.
LabelUpdateFailed— give it aRunReftoo- Signature becomes
func (n *Notifier) LabelUpdateFailed(r RunRef, add, remove []string, err error), titler.title("Label update failed"), descriptionr.describe(truncate(err.Error(), 500)), fieldsappend(r.fields(), Add…, Remove…)— the hand-rolledRun IDfield is dropped becauser.fields()already supplies it plusAttempt. - To hand it a real
RunRef(with the authoritative issue URL from search, not a derived one), changeOrchestrator.setLabels(internal/orchestrator/loop.go:836) from(ctx, log, cand candidate, runID string, add, remove []string)to(ctx, log, ref discord.RunRef, add, remove []string); inside, useref.Repo/ref.Issue/ref.RunIDin place ofcand.repo/cand.number/runID. - Update the six call sites: the three inside
execute(loop.go:533, 645, 721) already haverefin scope; inhandleFailure(loop.go:751, 768, 785) computeref := cand.ref(runID, attempt)once at the top and reuse it for the threeDiscord.Run*calls there as well.
5. Deliberately unchanged
GateClosed,GateCleared,ModelCooledDown,Paused,Resumed,DaemonStarted,DaemonStoppeddescribe daemon/account state, not a GitHub object — "applicable" in the issue does not cover them, and attaching an issue link to a global gate event would misrepresent it. (If the reviewer disagrees,GateClosedandModelCooledDownare both emitted fromexecutewhererefis in scope, so adding a link there is a two-line follow-up.)Files touched
internal/discord/notifier.go— embedURL,issueURL/describe/postRunhelpers, all run-scoped methods,RunCanceledandLabelUpdateFailedsignatures.internal/discord/notifier_test.go— new coverage, fix theLabelUpdateFailedcall.internal/orchestrator/loop.go—setLabelssignature and its six call sites;handleFailurecomputesrefonce.internal/server/server.go—cancelRunlooks the run up before notifying.README.md— "Discord notifications" section (~lines 450-472): note that every run notification links the issue, and update the "Draft PR opened" / "Run cancelled" bullets.
Tests
In
internal/discord/notifier_test.go, reusing the existingstubWebhook,waitForCount,decodeEmbed,field,testRefhelpers:- Table test over every run-scoped method (
RunClaimed,ClaudeFinished,VerifyResult,PROpened,PlanPosted,RunFailed,RunAbandoned,RunDeferred,RunCanceled,LabelUpdateFailed), each invoked withtestRef(), assertinge.URL != ""and that theIssuefield containshttps://github.com/acme/widgets/issues/42. This is the regression guard the issue is really asking for: a future notifier method that skips the link fails the test.decodeEmbedalready unmarshals intoembed, so it picks up the newURLfield for free. - Derived-URL test: a
RunRefwithURL: ""butRepo/Issueset still yieldshttps://github.com/acme/widgets/issues/42. - Degenerate
RunCanceled:RunRef{RunID: "run-1"}posts exactly one embed, with noIssuefield, nourl, and the plain title (no#0). PROpened:e.URLis the PR URL and theIssuefield still links the issue.RunFailed: description contains both the cause and the issue link.- Update
TestLabelUpdateFailedNamesTheLabelsfor the new signature, keeping its Add/Remove assertions.
In
internal/server/server_test.go: add a cancel case wherectrl.cancelOKis true but no run row exists, asserting 200 and no panic (the notifier is nil in those tests, which is already safe by design).Verification
go build ./... go test ./internal/discord/... ./internal/orchestrator/... ./internal/server/... go test ./...End-to-end (optional, needs a throwaway Discord channel): set
discord.enabled: trueand a realwebhook_urlinconfig.json, run the daemon against a labelled test issue with--once, and confirm that "Run claimed", "Claude finished"/"Run failed", and the PR or plan message each render a clickable title and anIssuefield.POST /runs/{id}/cancelagainst an in-flight run exercises theRunCanceledpath.Risks / decisions for the reviewer
- The derived URL assumes github.com. There is no host/enterprise setting anywhere in
internal/configorinternal/gh, sohttps://github.com/<repo>/issues/<n>is correct for this codebase today. It is only a fallback — the realurlfromgh search issues(candidate.url→RunRef.URL) is always preferred — but it would be wrong under aGH_HOSTpointing at GitHub Enterprise. Accepting this avoids growing a config knob. - Some duplication is intentional.
RunClaimedand friends will show the link twice (linked title in the description, plus theIssuefield). Suppressing the field when the description already contains the URL would make the rule conditional again — which is exactly the bug being fixed. - Two exported signatures change (
RunCanceled,LabelUpdateFailed) plus the unexportedsetLabels. The package is internal with one call site each, so the blast radius is contained. setLabelstaking adiscord.RunRefcouples the orchestrator's label bookkeeping to the notifier's type. The alternative — keep(cand, runID)and derive the URL insideLabelUpdateFailed— avoids that coupling but loses the authoritative issue URL.internal/orchestratoralready importsinternal/discordand constructsRunRefs, so the coupling is not new.
Reply with exactly
implementto approve this plan and start the change. Reply with anything else and the plan will be revised to address it.coding-agent-loop run
73dcd068-5ff3-4172-bf02-e905c5b94d75, modelclaude-opus-5, cost $1.2867- addedagent-plannedManaged by coding-agent-loopManaged by coding-agent-loopand removed
on Aug 22, 2026 Implement
Opened a draft pull request for this issue: #7
Tests failed (
make test) — see the PR for output.coding-agent-loop run
66b42c34-42d5-452c-8f45-10f512c2bf7c- added and removedagent-plannedManaged by coding-agent-loopManaged by coding-agent-loop
on Aug 22, 2026 - added a commit that references this issue
on Aug 25, 2026
Some of the notifications include the github link, but not all. Ensure that all applicable notifications include the github url, so that I can easily click it to navigate to the issue, pr, etc.