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
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,37 @@ describe("Lifecycle and Resilience", () => {
expect(conversation.title).toMatch(/leak/i);
});

test("when the first message links a tracked issue, name the conversation after the issue", async ({
agent,
}) => {
// The plugin stands in for a provider that reads the linked issue.
const issueTracker = defineJuniorPlugin({
manifest: {
name: "eval-issue-tracker",
displayName: "Eval Issue Tracker",
description: "Eval issue titles",
},
hooks: {
conversationTitleContext: ({ text }) =>
text.includes("tracker.example.test/acme/app/issues/1583")
? ["acme/app#1583: Checkout button freezes on Safari"]
: undefined,
},
});
const { run } = await agent({
plugins: defineJuniorPlugins([issueTracker]),
});

const conversation = await run(
slackMention(
"Fix this https://tracker.example.test/acme/app/issues/1583. For now, reply with exactly: on it",
),
);

expect(conversation.title).toMatch(/1583/);
expect(conversation.title).toMatch(/safari|checkout/i);
});

// A Slack turn compacts history before the agent starts. A web turn
// compacts it in the agent, before the first model request.
test.for([
Expand Down
230 changes: 230 additions & 0 deletions packages/junior-github/src/conversation-title-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import type { PluginLogger } from "@sentry/junior-plugin-api";
import { isRecord } from "./credential-support.js";

// Name a Conversation from its main objects, not from every reference it has.
const MAX_REFERENCES = 3;
const TITLE_MAX_LENGTH = 160;

const OWNER = String.raw`([A-Za-z0-9][A-Za-z0-9-]*)`;
const REPO = String.raw`([\w.-]*\w)`;
const SHA = String.raw`([0-9a-f]{7,40})`;
const NOT_AFTER_PATH = String.raw`(?<![\w/.-])`;
const REPO_IN_PROSE = String.raw`(?:on|in|from|of|at)\s+${OWNER}\/${REPO}`;

export interface GitHubReference {
/** Issue number, pull request number, or commit SHA. */
id: string;
/** `issue` also matches pull requests, as GitHub numbers them together. */
kind: "commit" | "issue" | "pull_request";
name: string;
owner: string;
}

interface Repository {
name: string;
owner: string;
}

interface ReferenceMatch {
end: number;
id: string;
kind: GitHubReference["kind"];
repository?: Repository;
start: number;
}

type ReferencePattern = {
pattern: RegExp;
read(match: RegExpMatchArray): Omit<ReferenceMatch, "end" | "start">;
};

const repository = (owner?: string, name?: string): Repository | undefined =>
owner && name ? { owner, name } : undefined;

// GitHub autolinked references, plus prose such as "issue 12 on owner/repo".
// https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/autolinked-references-and-urls
const REFERENCE_PATTERNS: ReferencePattern[] = [
{
pattern: new RegExp(
String.raw`https?:\/\/(?:www\.)?github\.com\/${OWNER}\/${REPO}\/(issues|pull)\/(\d+)`,
"gi",
),
read: ([, owner, name, path, id]) => ({
id: id!,
kind: path!.toLowerCase() === "pull" ? "pull_request" : "issue",
repository: repository(owner, name),
}),
},
{
pattern: new RegExp(
String.raw`https?:\/\/(?:www\.)?github\.com\/${OWNER}\/${REPO}\/commit\/${SHA}`,
"gi",
),
read: ([, owner, name, id]) => ({
id: id!,
kind: "commit",
repository: repository(owner, name),
}),
},
{
pattern: new RegExp(
String.raw`${NOT_AFTER_PATH}${OWNER}\/${REPO}#(\d+)\b`,
"g",
),
read: ([, owner, name, id]) => ({
id: id!,
kind: "issue",
repository: repository(owner, name),
}),
},
{
pattern: new RegExp(
String.raw`${NOT_AFTER_PATH}${OWNER}\/${REPO}@${SHA}\b`,
"g",
),
read: ([, owner, name, id]) => ({
id: id!,
kind: "commit",
repository: repository(owner, name),
}),
},
{
pattern: new RegExp(
String.raw`\b(issues?|bugs?|tickets?|prs?|pulls?|pull requests?)\s+(?:#|no\.?\s*|number\s+)?(\d+)\b(?:\s+${REPO_IN_PROSE})?`,
"gi",
),
read: ([, noun, id, owner, name]) => ({
id: id!,
kind: /^(?:prs?|pulls?|pull requests?)$/i.test(noun!)
? "pull_request"
: "issue",
repository: repository(owner, name),
}),
},
{
pattern: /(?<![\w/&])(?:#|\bGH-)(\d+)\b/gi,
read: ([, id]) => ({ id: id!, kind: "issue" }),
},
];

/** Read `repo` as `owner/name`, or as `name` with the `org` config value. */
function configuredRepository(
config: Record<string, string>,
): Repository | undefined {
const [first, second, ...extra] = (config.repo ?? "").trim().split("/");
if (extra.length > 0) return undefined;
return second
? repository(first, second)
: repository(config.org?.trim(), first);
}

/**
* Find distinct GitHub references in message text, in message order.
* A reference without a repository uses the one repository that the message
* names, or else the configured repository.
*/
function parseGitHubReferences(
text: string,
config: Record<string, string>,
): GitHubReference[] {
const matches = REFERENCE_PATTERNS.flatMap(({ pattern, read }) =>
[...text.matchAll(pattern)].map((match) => ({
...read(match),
end: match.index + match[0].length,
start: match.index,
})),
).sort((left, right) => left.start - right.start || right.end - left.end);

const accepted: ReferenceMatch[] = [];
for (const match of matches) {
const previous = accepted.at(-1);
if (!previous || match.start >= previous.end) accepted.push(match);
}

const namedRepositories = new Map<string, Repository>();
for (const match of accepted) {
if (match.repository) {
const key = `${match.repository.owner}/${match.repository.name}`;
namedRepositories.set(key.toLowerCase(), match.repository);
}
}
for (const match of text.matchAll(new RegExp(REPO_IN_PROSE, "gi"))) {
const named = repository(match[1], match[2]);
if (named) {
namedRepositories.set(
`${named.owner}/${named.name}`.toLowerCase(),
named,
);
}
}
const fallback =
namedRepositories.size === 1
? [...namedRepositories.values()][0]
: configuredRepository(config);

const references = new Map<string, GitHubReference>();
for (const match of accepted) {
const target = match.repository ?? fallback;
if (!target) continue;
const key =
`${target.owner}/${target.name}${match.kind === "commit" ? "@" : "#"}${match.id}`.toLowerCase();
if (references.has(key)) continue;
references.set(key, { ...target, id: match.id, kind: match.kind });
if (references.size >= MAX_REFERENCES) break;
}
return [...references.values()];
}

/** Read the display title of an issue, pull request, or commit response. */
function objectTitle(
kind: GitHubReference["kind"],
object: unknown,
): string | undefined {
if (!isRecord(object)) return undefined;
const raw =
kind === "commit"
? isRecord(object.commit) && typeof object.commit.message === "string"
? object.commit.message.split("\n")[0]
: undefined
: typeof object.title === "string"
? object.title
: undefined;
return raw?.replace(/\s+/g, " ").trim() || undefined;
}

/**
* Describe GitHub issues, pull requests, and commits that message text
* references, as `owner/repo#123: Title` or `owner/repo@abc1234: Subject`.
* References that `loadObject` cannot read are left out.
*/
export async function loadGitHubConversationTitleContext(args: {
config: Record<string, string>;
loadObject(reference: GitHubReference): Promise<unknown>;
log: PluginLogger;
text: string;
}): Promise<string[]> {
const lines = await Promise.all(
parseGitHubReferences(args.text, args.config).map(async (reference) => {
try {
const title = objectTitle(
reference.kind,
await args.loadObject(reference),
);
if (!title) return undefined;
const label =
reference.kind === "commit"
? `${reference.owner}/${reference.name}@${reference.id.slice(0, 7)}`
: `${reference.owner}/${reference.name}#${reference.id}`;
return `${label}: ${title.slice(0, TITLE_MAX_LENGTH)}`;
} catch (error) {
args.log.warn("github.conversation_title_context.load_failed", {
"app.github.object_kind": reference.kind,
"exception.message":
error instanceof Error ? error.message : String(error),
});
return undefined;
}
}),
);
return lines.filter((line): line is string => Boolean(line));
}
44 changes: 44 additions & 0 deletions packages/junior-github/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
prepareCommitMsgHook,
} from "./git-config.js";
import { linkifyGitHubReferences } from "./reply-markdown.js";
import { loadGitHubConversationTitleContext } from "./conversation-title-context.js";
import { prepareWorkspace } from "./workspace-prepare.js";
import {
githubGrantForEgress,
Expand Down Expand Up @@ -246,6 +247,49 @@ export function githubPlugin(
],
},
hooks: {
async conversationTitleContext(ctx) {
return await loadGitHubConversationTitleContext({
config: ctx.config,
log: ctx.log,
text: ctx.text,
loadObject: async (reference) => {
const { collection, operation } = (
{
commit: {
collection: "commits",
operation: "github.commit.get",
},
issue: { collection: "issues", operation: "github.issue.get" },
pull_request: {
collection: "pulls",
operation: "github.pull.get",
},
} as const
)[reference.kind];
// Read through host egress so policy, grants, and audit apply.
const response = await ctx.egress.fetch({
provider: "github",
operation,
request: new Request(
`https://api.github.com/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.name)}/${collection}/${encodeURIComponent(reference.id)}`,
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
signal: ctx.signal,
},
),
});
if (!response.ok) {
throw new Error(
`GitHub ${reference.kind} lookup failed with HTTP ${response.status}`,
);
}
return await response.json();
},
});
},
conversationSidebar(ctx) {
return {
annotationsByConversationId: Object.fromEntries(
Expand Down
Loading
Loading