Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions packages/junior-github/src/tool-support/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
Actor,
Identity,
PluginLogger,
Source,
User,
} from "@sentry/junior-plugin-api";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Comment thread
sentry-junior[bot] marked this conversation as resolved.
.map((entry) => entry.trim().replace(/^via\s+/i, ""))
.filter(Boolean);
}
Expand Down Expand Up @@ -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>;
};
Expand All @@ -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,
}),
);
Expand Down
3 changes: 3 additions & 0 deletions packages/junior-github/src/tools/update-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type Actor,
type PluginLogger,
type PluginEgress,
type Source,
definePluginTool,
PluginToolInputError,
pluginToolOutputSchema,
Expand Down Expand Up @@ -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>;
};
Expand Down
3 changes: 3 additions & 0 deletions packages/junior-github/src/tools/update-pull-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type Actor,
type PluginLogger,
type PluginEgress,
type Source,
definePluginTool,
PluginToolInputError,
pluginToolOutputSchema,
Expand Down Expand Up @@ -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>;
};
Expand Down
36 changes: 35 additions & 1 deletion packages/junior-github/tests/github-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class TestTokenStore {
type TestActor = {
email?: string;
fullName?: string;
name?: string;
platform?: string;
teamId?: string;
userId?: string;
Expand Down Expand Up @@ -343,6 +344,7 @@ function githubToolsContext(input?: {
request: Request;
}) => Promise<Response>;
resolveActor?: ToolRegistrationHookContext["users"]["resolveActor"];
source?: ToolRegistrationHookContext["source"];
stateSet?: (input: { key: string; value: unknown }) => Promise<void> | void;
subscribe?: ToolRegistrationHookContext["events"]["subscribe"];
}) {
Expand All @@ -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,
Expand Down Expand Up @@ -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<!-- junior-request-attribution:start -->\nvia [automation `evt_1`](https://junior.example.com/automations/evt_1) · Run ID `dispatch_1`.\n<!-- junior-request-attribution:end -->",
});
});

it("keeps issue annotation labels compact for long titles", async () => {
const ctx = githubToolsContext();
const tool = githubPlugin().hooks?.tools?.(ctx as any)?.createIssue;
Expand Down
16 changes: 14 additions & 2 deletions packages/junior-plugin-api/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 2 additions & 0 deletions packages/junior-plugin-api/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 15 additions & 2 deletions packages/junior/src/chat/agent-dispatch/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<SlackDispatchOptions, "credentialSubject"> & {
Expand Down Expand Up @@ -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,
Expand All @@ -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<SlackDispatchOptions, "credentialSubject"> & {
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion packages/junior/src/chat/agent-dispatch/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions packages/junior/src/chat/event-automations/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export async function ingestEventAutomations(
}
: undefined;
const dispatch = await dispatchEventAutomation({
automationId: task.id,
conversationWorkQueue: options.queue,
nowMs,
options: {
Expand Down
14 changes: 13 additions & 1 deletion packages/junior/src/chat/plugins/agent-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
8 changes: 8 additions & 0 deletions packages/junior/tests/integration/event-automations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading