Skip to content

Always include the github url in the Discord notification #6

Description

@ableinc

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.

Activity

  1. ableinc commented on Aug 22, 2026

    @ableinc
    OwnerAuthor

    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 Issue field — and give the two notifications that currently have no GitHub context at all (RunCanceled, LabelUpdateFailed) a proper RunRef.

    (Note: the plan file could not be written — /home/node1/.claude/plans is on a read-only filesystem. The plan is below.)

    Context

    internal/discord/notifier.go renders every notification as a Discord embed. Today the link only appears where RunRef.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, RunDeferred overwrite Description with the cause/reason text, so the issue link disappears — only the plain-text owner/name#42 in the title survives.
    • PlanPosted sets Description to "Reply implement on the issue…" — an instruction to go to the issue, with no link to it.
    • PROpened shows 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 theembed struct (notifier.go:82). Discord renders embed.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 — returns r.URL when set; otherwise derives https://github.com/<Repo>/issues/<Issue> when Repo != "" and Issue > 0; otherwise "".
      • func (r RunRef) describe(body string) string — returns r.description() when body is empty, body when there is no description, and description() + "\n" + body otherwise. 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 RunRef built from the store (which has no issue-URL column) still links correctly.

    2. Route every run-scoped method through postRun

    Change n.post(embed{…}) → n.postRun(r, embed{…}) in RunClaimed, 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: set URL: prURL on the embed explicitly (title links to the PR; postRun leaves a non-empty URL alone), Description: r.describe(prURL), and add a {Name: "Pull request", Value: prURL} field. The Issue field added by postRun then gives both links in one message.

    3. RunCanceled — give it a RunRef

    • Signature becomes func (n *Notifier) RunCanceled(r RunRef). Title: r.title("Run cancelled by operator") when r.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 via r.fields().
    • Call site internal/server/server.go:264 (cancelRun): after s.ctrl.Cancel(id) succeeds, look the run up with the existing s.store.GetRun(c.Context(), id) (internal/store/store.go:538, returns Repo, Issue, Attempt). On success post discord.RunRef{Repo: run.Repo, Issue: run.Issue, RunID: id, Attempt: run.Attempt}; on error log and post discord.RunRef{RunID: id}. The lookup must never change the HTTP result.

    4. LabelUpdateFailed — give it a RunRef too

    • Signature becomes func (n *Notifier) LabelUpdateFailed(r RunRef, add, remove []string, err error), title r.title("Label update failed"), description r.describe(truncate(err.Error(), 500)), fields append(r.fields(), Add…, Remove…) — the hand-rolled Run ID field is dropped because r.fields() already supplies it plus Attempt.
    • To hand it a real RunRef (with the authoritative issue URL from search, not a derived one), change Orchestrator.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, use ref.Repo / ref.Issue / ref.RunID in place of cand.repo / cand.number / runID.
    • Update the six call sites: the three inside execute (loop.go:533, 645, 721) already have ref in scope; in handleFailure (loop.go:751, 768, 785) compute ref := cand.ref(runID, attempt) once at the top and reuse it for the three Discord.Run* calls there as well.

    5. Deliberately unchanged

    GateClosed, GateCleared, ModelCooledDown, Paused, Resumed, DaemonStarted, DaemonStopped describe 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, GateClosed and ModelCooledDown are both emitted from execute where ref is in scope, so adding a link there is a two-line follow-up.)

    Files touched

    • internal/discord/notifier.go — embed URL, issueURL/describe/postRun helpers, all run-scoped methods, RunCanceled and LabelUpdateFailed signatures.
    • internal/discord/notifier_test.go — new coverage, fix the LabelUpdateFailed call.
    • internal/orchestrator/loop.go — setLabels signature and its six call sites; handleFailure computes ref once.
    • internal/server/server.go — cancelRun looks 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 existing stubWebhook, waitForCount, decodeEmbed, field, testRef helpers:

    • Table test over every run-scoped method (RunClaimed, ClaudeFinished, VerifyResult, PROpened, PlanPosted, RunFailed, RunAbandoned, RunDeferred, RunCanceled, LabelUpdateFailed), each invoked with testRef(), asserting e.URL != "" and that the Issue field contains https://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. decodeEmbed already unmarshals into embed, so it picks up the new URL field for free.
    • Derived-URL test: a RunRef with URL: "" but Repo/Issue set still yields https://github.com/acme/widgets/issues/42.
    • Degenerate RunCanceled: RunRef{RunID: "run-1"} posts exactly one embed, with no Issue field, no url, and the plain title (no #0).
    • PROpened: e.URL is the PR URL and the Issue field still links the issue.
    • RunFailed: description contains both the cause and the issue link.
    • Update TestLabelUpdateFailedNamesTheLabels for the new signature, keeping its Add/Remove assertions.

    In internal/server/server_test.go: add a cancel case where ctrl.cancelOK is 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: true and a real webhook_url in config.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 an Issue field. POST /runs/{id}/cancel against an in-flight run exercises the RunCanceled path.

    Risks / decisions for the reviewer

    • The derived URL assumes github.com. There is no host/enterprise setting anywhere in internal/config or internal/gh, so https://github.com/<repo>/issues/<n> is correct for this codebase today. It is only a fallback — the real url from gh search issues (candidate.url → RunRef.URL) is always preferred — but it would be wrong under a GH_HOST pointing at GitHub Enterprise. Accepting this avoids growing a config knob.
    • Some duplication is intentional. RunClaimed and friends will show the link twice (linked title in the description, plus the Issue field). 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 unexported setLabels. The package is internal with one call site each, so the blast radius is contained.
    • setLabels taking a discord.RunRef couples the orchestrator's label bookkeeping to the notifier's type. The alternative — keep (cand, runID) and derive the URL inside LabelUpdateFailed — avoids that coupling but loses the authoritative issue URL. internal/orchestrator already imports internal/discord and constructs RunRefs, so the coupling is not new.

    Reply with exactly implement to 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, model claude-opus-5, cost $1.2867

  2. ableinc commented on Aug 22, 2026

    @ableinc
    OwnerAuthor

    Implement

  3. ableinc commented on Aug 22, 2026

    @ableinc
    OwnerAuthor

    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

  4. added a commit that references this issue on Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions