From af181a87e4bc1501d7546ed9465f5d063b197427 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:27:33 +0000 Subject: [PATCH] fix(github): Attribute automation writes to the Automation run Automation writes showed "via Junior system actor `junior`" because the Source had no Automation id. Event and scheduled automation Sources now carry the Automation id and run id, and the GitHub attribution links the Automation and names the run. Other system actors get no label. Co-Authored-By: David Cramer --- .../src/tool-support/attribution.ts | 53 +++++++++++++++---- .../junior-github/src/tools/update-issue.ts | 3 ++ .../src/tools/update-pull-request.ts | 3 ++ .../junior-github/tests/github-plugin.test.ts | 36 ++++++++++++- packages/junior-plugin-api/src/schemas.ts | 16 +++++- packages/junior-plugin-api/src/tools.ts | 2 + .../junior/src/chat/agent-dispatch/context.ts | 17 +++++- .../junior/src/chat/agent-dispatch/store.ts | 6 ++- .../src/chat/event-automations/ingest.ts | 1 + .../junior/src/chat/plugins/agent-hooks.ts | 14 ++++- .../chat/scheduled-automations/heartbeat.ts | 1 + .../integration/event-automations.test.ts | 8 +++ .../heartbeat-scheduled-automations.test.ts | 6 ++- 13 files changed, 147 insertions(+), 19 deletions(-) diff --git a/packages/junior-github/src/tool-support/attribution.ts b/packages/junior-github/src/tool-support/attribution.ts index 52dc3fe36e..bbb9419a9d 100644 --- a/packages/junior-github/src/tool-support/attribution.ts +++ b/packages/junior-github/src/tool-support/attribution.ts @@ -2,6 +2,7 @@ import type { Actor, Identity, PluginLogger, + Source, User, } from "@sentry/junior-plugin-api"; @@ -53,25 +54,50 @@ function cleanDisplayValue( return cleaned; } +/** Name the Automation run that made this write. */ +function automationLabel( + source: Source | undefined, + automationUrl: string | undefined, +): string | undefined { + if ( + (source?.kind !== "event_automation" && + source?.kind !== "scheduled_automation") || + !source.automationId + ) { + return undefined; + } + const automation = `automation \`${source.automationId}\``; + const label = automationUrl + ? `[${automation}](${automationUrl})` + : automation; + return source.automationRunId + ? `${label} · Run ID \`${source.automationRunId}\`` + : label; +} + /** * Resolve the requester display label through the host identity path first. * - * Prefer a linked GitHub login as an `@` mention so GitHub subscribes the - * requester. Otherwise use the linked user name, then the stored identity - * name/handle, then the already-hydrated actor profile. Never publish a raw - * actor id. + * Automation runs use the automation label. Other system actors get no label + * because their names are internal. For people, prefer a linked GitHub login + * as an `@` mention so GitHub subscribes the requester. Otherwise use the + * linked user name, then the stored identity name/handle, then the + * already-hydrated actor profile. Never publish a raw actor id. */ function requesterLabel(args: { actor: Actor | undefined; + automationUrl?: string; identity?: Identity; + source?: Source; user?: User; }): string | undefined { const { actor, identity, user } = args; - if (!actor) { - return undefined; + const automation = automationLabel(args.source, args.automationUrl); + if (automation) { + return automation; } - if (actor.platform === "system") { - return `Junior system actor \`${actor.name}\``; + if (!actor || actor.platform === "system") { + return undefined; } const login = linkedGitHubLogin(user); @@ -101,10 +127,11 @@ function parseExistingLabels(blockContents: string): string[] { } const withoutLeadIn = trimmed.replace(/^(?:Requested by|via)\s+/i, ""); // Only split on a comma that starts the next label (bold markdown, a GitHub - // mention, a system-actor label, or a legacy repeated "via") so a display - // name that itself contains a comma is not broken into extra labels. + // mention, an automation label, a system-actor label, or a legacy repeated + // "via") so a display name that itself contains a comma is not broken into + // extra labels. return withoutLeadIn - .split(/,\s*(?=\*\*|@|Junior system actor `|via\s)/i) + .split(/,\s*(?=\*\*|@|\[?automation `|Junior system actor `|via\s)/i) .map((entry) => entry.trim().replace(/^via\s+/i, "")) .filter(Boolean); } @@ -152,7 +179,9 @@ export async function appendGitHubRequesterAttribution( body: string, ctx: { actor?: Actor; + automationLink?: { url: string }; log: PluginLogger; + source?: Source; users: { resolveActor(): Promise<{ identity?: Identity; user?: User } | undefined>; }; @@ -178,7 +207,9 @@ export async function appendGitHubRequesterAttribution( body, requesterLabel({ actor: ctx.actor, + automationUrl: ctx.automationLink?.url, identity: resolved?.identity, + source: ctx.source, user: resolved?.user, }), ); diff --git a/packages/junior-github/src/tools/update-issue.ts b/packages/junior-github/src/tools/update-issue.ts index 8f27872f22..ea170a9897 100644 --- a/packages/junior-github/src/tools/update-issue.ts +++ b/packages/junior-github/src/tools/update-issue.ts @@ -6,6 +6,7 @@ import { type Actor, type PluginLogger, type PluginEgress, + type Source, definePluginTool, PluginToolInputError, pluginToolOutputSchema, @@ -100,11 +101,13 @@ function githubApiErrorMessage(payload: unknown): string { /** Update mutable issue metadata while preserving runtime-owned body attribution. */ export function createGitHubUpdateIssueTool(ctx: { actor?: Actor; + automationLink?: { url: string }; conversationId?: string; egress: PluginEgress; log: PluginLogger; events: { canSubscribe: boolean }; slack?: { conversationLink?: { url?: string } }; + source?: Source; users: { resolveActor(): Promise<{ identity?: Identity; user?: User } | undefined>; }; diff --git a/packages/junior-github/src/tools/update-pull-request.ts b/packages/junior-github/src/tools/update-pull-request.ts index 37ad507649..4c7e6dbb63 100644 --- a/packages/junior-github/src/tools/update-pull-request.ts +++ b/packages/junior-github/src/tools/update-pull-request.ts @@ -6,6 +6,7 @@ import { type Actor, type PluginLogger, type PluginEgress, + type Source, definePluginTool, PluginToolInputError, pluginToolOutputSchema, @@ -181,11 +182,13 @@ function githubGraphqlErrorMessage(payload: unknown): string { /** Update mutable PR metadata while preserving runtime-owned body attribution. */ export function createGitHubUpdatePullRequestTool(ctx: { actor?: Actor; + automationLink?: { url: string }; conversationId?: string; egress: PluginEgress; log: PluginLogger; events: { canSubscribe: boolean }; slack?: { conversationLink?: { url?: string } }; + source?: Source; users: { resolveActor(): Promise<{ identity?: Identity; user?: User } | undefined>; }; diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index c884f90517..f4c41ed510 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -62,6 +62,7 @@ class TestTokenStore { type TestActor = { email?: string; fullName?: string; + name?: string; platform?: string; teamId?: string; userId?: string; @@ -343,6 +344,7 @@ function githubToolsContext(input?: { request: Request; }) => Promise; resolveActor?: ToolRegistrationHookContext["users"]["resolveActor"]; + source?: ToolRegistrationHookContext["source"]; stateSet?: (input: { key: string; value: unknown }) => Promise | void; subscribe?: ToolRegistrationHookContext["events"]["subscribe"]; }) { @@ -360,7 +362,7 @@ function githubToolsContext(input?: { ...(input?.actor ? { actor: input.actor } : undefined), conversationId, destination: { platform: "local" as const, conversationId }, - source: { + source: input?.source ?? { kind: "local" as const, visibility: "private" as const, conversationId, @@ -1029,6 +1031,38 @@ describe("github plugin", () => { }); }); + it("attributes automation writes to the Automation run", async () => { + process.env.GITHUB_WEBHOOK_SECRET = "test-secret"; + const ctx = { + ...githubToolsContext({ + actor: { platform: "system", name: "junior" }, + conversationId: "agent-dispatch:dispatch_1", + source: { + kind: "event_automation", + automationId: "evt_1", + automationRunId: "dispatch_1", + }, + }), + automationLink: { url: "https://junior.example.com/automations/evt_1" }, + }; + const tool = githubPlugin().hooks?.tools?.(ctx as any)?.createIssue; + + await tool?.execute?.( + { + repo: "getsentry/junior", + title: "Typed issue", + body: "Issue body", + labels: ["bug"], + }, + { toolCallId: "call-create-issue-automation" }, + ); + + const request = ctx.egressRequests()[0]; + await expect(request?.request.json()).resolves.toMatchObject({ + body: "Issue body\n\n\nvia [automation `evt_1`](https://junior.example.com/automations/evt_1) · Run ID `dispatch_1`.\n", + }); + }); + it("keeps issue annotation labels compact for long titles", async () => { const ctx = githubToolsContext(); const tool = githubPlugin().hooks?.tools?.(ctx as any)?.createIssue; diff --git a/packages/junior-plugin-api/src/schemas.ts b/packages/junior-plugin-api/src/schemas.ts index 895ab1632e..967898fa05 100644 --- a/packages/junior-plugin-api/src/schemas.ts +++ b/packages/junior-plugin-api/src/schemas.ts @@ -130,14 +130,26 @@ export const eventSourceSchema = z }) .strict(); +// Stored automation Sources from before these fields existed omit them. +const automationSourceFields = { + automationId: exactNonBlankStringSchema.optional(), + automationRunId: exactNonBlankStringSchema.optional(), +}; + /** Runtime-owned Scheduled automation input Source. */ export const scheduledAutomationSourceSchema = z - .object({ kind: z.literal("scheduled_automation") }) + .object({ + kind: z.literal("scheduled_automation"), + ...automationSourceFields, + }) .strict(); /** Runtime-owned Event automation input Source. */ export const eventAutomationSourceSchema = z - .object({ kind: z.literal("event_automation") }) + .object({ + kind: z.literal("event_automation"), + ...automationSourceFields, + }) .strict(); /** Runtime-owned Plugin dispatch input Source. */ diff --git a/packages/junior-plugin-api/src/tools.ts b/packages/junior-plugin-api/src/tools.ts index 7822da1513..b379c280ed 100644 --- a/packages/junior-plugin-api/src/tools.ts +++ b/packages/junior-plugin-api/src/tools.ts @@ -571,6 +571,8 @@ interface BaseToolRegistrationHookContext extends PluginContext { */ conversationId?: string; annotations?: PluginAnnotations; + /** Host-owned dashboard link to the Automation that started this run. */ + automationLink?: { url: string }; embedder: PluginEmbedder; egress: PluginEgress; mcp?: PluginMcp; diff --git a/packages/junior/src/chat/agent-dispatch/context.ts b/packages/junior/src/chat/agent-dispatch/context.ts index be438ed8a8..ac630becee 100644 --- a/packages/junior/src/chat/agent-dispatch/context.ts +++ b/packages/junior/src/chat/agent-dispatch/context.ts @@ -12,6 +12,7 @@ import { createPluginLogger } from "@/chat/plugins/logging"; import { createPluginState } from "@/chat/plugins/state"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { + buildDispatchId, createOrGetDispatch, getPluginDispatchProjection, isTerminalDispatchStatus, @@ -130,6 +131,7 @@ export function createHeartbeatContext(args: { /** Create and enqueue one core-owned event automation dispatch. */ export async function dispatchEventAutomation(args: { + automationId: string; conversationWorkQueue: ConversationWorkQueue; nowMs: number; options: Omit & { @@ -157,7 +159,12 @@ export async function dispatchEventAutomation(args: { ...unboundOptions, ...(outcomes !== undefined ? { outcomes } : undefined), ...(boundSubject ? { credentialSubject: boundSubject } : undefined), - source: { kind: "event_automation" }, + source: { + kind: "event_automation", + automationId: args.automationId, + // An event automation run id is its dispatch id. + automationRunId: buildDispatchId(plugin, unboundOptions.idempotencyKey), + }, }; return await dispatch({ conversationWorkQueue: args.conversationWorkQueue, @@ -169,6 +176,7 @@ export async function dispatchEventAutomation(args: { /** Create and enqueue one core-owned scheduled automation dispatch. */ export async function dispatchScheduledAutomation(args: { + automationId: string; conversationWorkQueue: ConversationWorkQueue; nowMs: number; options: Omit & { @@ -196,7 +204,12 @@ export async function dispatchScheduledAutomation(args: { ...unboundOptions, ...(outcomes !== undefined ? { outcomes } : undefined), ...(boundSubject ? { credentialSubject: boundSubject } : undefined), - source: { kind: "scheduled_automation" }, + source: { + kind: "scheduled_automation", + automationId: args.automationId, + // A scheduled automation run id is its idempotency key. + automationRunId: unboundOptions.idempotencyKey, + }, }; return await dispatch({ conversationWorkQueue: args.conversationWorkQueue, diff --git a/packages/junior/src/chat/agent-dispatch/store.ts b/packages/junior/src/chat/agent-dispatch/store.ts index e2372be10d..370aea70dc 100644 --- a/packages/junior/src/chat/agent-dispatch/store.ts +++ b/packages/junior/src/chat/agent-dispatch/store.ts @@ -163,7 +163,11 @@ function normalizeMetadata( const DISPATCH_ID_PREFIX = "agent-dispatch:"; -function buildDispatchId(plugin: string, idempotencyKey: string): string { +/** Return the stable dispatch id for one plugin idempotency key. */ +export function buildDispatchId( + plugin: string, + idempotencyKey: string, +): string { const digest = createHash("sha256") .update(plugin) .update("\0") diff --git a/packages/junior/src/chat/event-automations/ingest.ts b/packages/junior/src/chat/event-automations/ingest.ts index 08df389dce..a3f649c64e 100644 --- a/packages/junior/src/chat/event-automations/ingest.ts +++ b/packages/junior/src/chat/event-automations/ingest.ts @@ -154,6 +154,7 @@ export async function ingestEventAutomations( } : undefined; const dispatch = await dispatchEventAutomation({ + automationId: task.id, conversationWorkQueue: options.queue, nowMs, options: { diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 05ccbb50ec..cdb64aa0c5 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -46,7 +46,10 @@ import { createPluginState } from "@/chat/plugins/state"; import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; import { runNonInteractiveCommand } from "@/chat/sandbox/noninteractive-command"; import type { AnyToolDefinition } from "@/chat/tools/definition"; -import { getDashboardConversationLink } from "@/chat/dashboard-link"; +import { + getDashboardConversationLink, + getDashboardTaskLink, +} from "@/chat/dashboard-link"; import { createWatch } from "@/chat/events/store"; import { RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS } from "@/chat/events/tool-support"; @@ -701,9 +704,18 @@ export function getPluginTools( }; const resolveActor = context.resolveActorIdentity ?? (async () => undefined); + const automationUrl = + (context.source.kind === "event_automation" || + context.source.kind === "scheduled_automation") && + context.source.automationId + ? getDashboardTaskLink(context.source.automationId) + : undefined; const common = { ...basePluginContext(plugin), annotations, + ...(automationUrl + ? { automationLink: { url: automationUrl } } + : undefined), conversationId: context.conversationId, locationId: context.locationId, ...(slackContext ? { slack: slackContext } : undefined), diff --git a/packages/junior/src/chat/scheduled-automations/heartbeat.ts b/packages/junior/src/chat/scheduled-automations/heartbeat.ts index 973e98e063..55bb73b551 100644 --- a/packages/junior/src/chat/scheduled-automations/heartbeat.ts +++ b/packages/junior/src/chat/scheduled-automations/heartbeat.ts @@ -364,6 +364,7 @@ export async function runScheduledAutomationHeartbeat(args: { let dispatch; try { dispatch = await dispatchScheduledAutomation({ + automationId: task.id, conversationWorkQueue: args.conversationWorkQueue, nowMs: args.nowMs, options: { diff --git a/packages/junior/tests/integration/event-automations.test.ts b/packages/junior/tests/integration/event-automations.test.ts index e1bc56f81a..140638a3e4 100644 --- a/packages/junior/tests/integration/event-automations.test.ts +++ b/packages/junior/tests/integration/event-automations.test.ts @@ -343,6 +343,14 @@ describe("event automations", () => { }), ]), ); + // Each dispatch Source names its Automation and run. + for (const dispatch of dispatches) { + expect(dispatch!.source).toEqual({ + kind: "event_automation", + automationId: expect.any(String), + automationRunId: dispatch!.id, + }); + } // Each dispatch starts one run that links its Conversation. const readRuns = () => db diff --git a/packages/junior/tests/integration/heartbeat-scheduled-automations.test.ts b/packages/junior/tests/integration/heartbeat-scheduled-automations.test.ts index b2b64b558b..b0cd86fd2a 100644 --- a/packages/junior/tests/integration/heartbeat-scheduled-automations.test.ts +++ b/packages/junior/tests/integration/heartbeat-scheduled-automations.test.ts @@ -206,7 +206,11 @@ describe("scheduled automation heartbeat", () => { ); expect(dispatchRecord?.destination).toEqual(SLACK_DESTINATION); expect(dispatchRecord?.destinationVisibility).toBe("public"); - expect(dispatchRecord?.source).toEqual({ kind: "scheduled_automation" }); + expect(dispatchRecord?.source).toEqual({ + kind: "scheduled_automation", + automationId: "sched_plugin_1", + automationRunId: `sched_plugin_1:${TEST_RUN_AT_MS}`, + }); expect(dispatchRecord?.metadata).toMatchObject({ runId: `sched_plugin_1:${TEST_RUN_AT_MS}`, schedule: "Once at noon",