Skip to content
Draft
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 @@ -8,13 +8,17 @@ import {
slackLocationLabel,
} from "../format";
import type { Conversation } from "../types";
import { cn } from "../styles";

/** Render the shared conversation title and identity. */
export function ConversationSummary(props: { conversation: Conversation }) {
return (
<div className="min-w-0">
<div className="min-w-0 truncate text-base font-bold leading-tight text-dashboard-text">
{conversationDisplayTitle(props.conversation)}
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 truncate text-base font-bold leading-tight text-dashboard-text">
{conversationDisplayTitle(props.conversation)}
</div>
<PullRequestBadge conversation={props.conversation} />
</div>
<div className="mt-1 break-words text-sm leading-relaxed text-dashboard-text-muted md:truncate">
<ConversationIdentity conversation={props.conversation} />
Expand All @@ -23,6 +27,38 @@ export function ConversationSummary(props: { conversation: Conversation }) {
);
}

function PullRequestBadge(props: { conversation: Conversation }) {
const pullRequest = props.conversation.pullRequest;
if (!pullRequest) return null;
const label =
pullRequest.status === "draft"
? "PR draft"
: pullRequest.status === "open"
? "PR ready"
: "PR merged";
return (
<a
aria-label={`${pullRequest.label}: ${label}`}
className={cn(
"shrink-0 rounded border px-1.5 py-0.5 font-mono text-xs font-bold uppercase tracking-wide",
pullRequest.status === "draft" &&
"border-amber-300/35 bg-amber-300/10 text-amber-200",
pullRequest.status === "open" &&
"border-cyan-300/35 bg-cyan-300/10 text-cyan-200",
pullRequest.status === "merged" &&
"border-violet-300/35 bg-violet-300/10 text-violet-200",
)}
href={pullRequest.url}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
rel="noreferrer"
target="_blank"
>
{label}
</a>
);
}

function ConversationIdentity(props: { conversation: Conversation }) {
const email = props.conversation.actorIdentity?.email?.trim();
const owner = conversationActorLabel(props.conversation);
Expand Down
1 change: 1 addition & 0 deletions packages/junior-dashboard/src/client/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,7 @@ export function buildConversations(
lastProgressAt: summary.lastProgressAt,
lastSeenAt: summary.lastSeenAt,
locationId: summary.locationId,
pullRequest: summary.pullRequest,
actorIdentity: summary.actorIdentity,
sentryTraceUrl: summary.sentryTraceUrl,
sourceUrl: summary.sourceUrl,
Expand Down
1 change: 1 addition & 0 deletions packages/junior-dashboard/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export type Conversation = {
lastProgressAt: string;
lastSeenAt: string;
locationId?: string;
pullRequest?: ConversationSummaryReport["pullRequest"];
actorIdentity?: ConversationSummaryReport["actorIdentity"];
sentryTraceUrl?: string;
sourceUrl?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/junior-github/src/pull-request-outcomes/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const githubPullRequestOutcomeInputSchema = z
candidateOwned: z.boolean(),
closedAt: z.date().optional(),
commitComposition: githubPullRequestCommitCompositionSchema.optional(),
draft: z.boolean(),
mergedAt: z.date().optional(),
number: z.number().int().positive(),
openedAt: z.date(),
Expand Down
86 changes: 48 additions & 38 deletions packages/junior-github/src/webhooks/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,28 +141,56 @@ export function createGitHubWebhookRoute(args: {
eventName === "pull_request"
? normalizeGitHubPullRequestLinkedIssues({ body, botEmail })
: undefined;
if (pullRequestOutcome) {
const recordedOutcome = await recordGitHubPullRequestOutcome(
args.db,
pullRequestOutcome,
const recordedOutcome = pullRequestOutcome
? await recordGitHubPullRequestOutcome(args.db, pullRequestOutcome)
: undefined;
if (issueOutcome) {
await recordGitHubIssueOutcome(args.db, issueOutcome);
}
const recordedIssueConversations = issueConversations
? await recordGitHubIssueConversations(args.db, issueConversations)
: false;
const recordedPullRequestConversations = pullRequestConversations
? await recordGitHubPullRequestConversations(
args.db,
pullRequestConversations,
)
: false;
const recordedPullRequestLinkedIssues = pullRequestLinkedIssues
? await recordGitHubPullRequestLinkedIssues(
args.db,
pullRequestLinkedIssues,
)
: false;
if (pullRequestOutcome && recordedOutcome?.applied) {
const conversationIds = [
...new Set([
...recordedOutcome.conversationIds,
...(recordedPullRequestConversations && pullRequestConversations
? pullRequestConversations.conversationIds
: []),
]),
];
const status =
pullRequestOutcome.state === "merged"
? "merged"
: pullRequestOutcome.state === "closed_unmerged"
? "closed"
: pullRequestOutcome.draft
? "draft"
: "open";
await Promise.all(
conversationIds.map((conversationId) =>
args.annotations.forConversation(conversationId).upsert({
kind: "resource_link",
key: `${pullRequestOutcome.repositoryFullName.toLowerCase()}#${pullRequestOutcome.number}`,
label: `${pullRequestOutcome.repositoryFullName}#${pullRequestOutcome.number}`,
url: `https://github.com/${pullRequestOutcome.repositoryFullName}/pull/${pullRequestOutcome.number}`,
status,
}),
),
);
if (recordedOutcome.applied && pullRequestOutcome.state !== "open") {
const status =
pullRequestOutcome.state === "merged" ? "merged" : "closed";
await Promise.all(
recordedOutcome.conversationIds.map((conversationId) =>
args.annotations.forConversation(conversationId).upsert({
kind: "resource_link",
key: `${pullRequestOutcome.repositoryFullName.toLowerCase()}#${pullRequestOutcome.number}`,
label: `${pullRequestOutcome.repositoryFullName}#${pullRequestOutcome.number}`,
url: `https://github.com/${pullRequestOutcome.repositoryFullName}/pull/${pullRequestOutcome.number}`,
status,
}),
),
);
}
if (
recordedOutcome.applied &&
!recordedOutcome.commitComposition &&
pullRequestOutcome.state === "merged" &&
args.classifyPullRequestCommits
Expand All @@ -189,24 +217,6 @@ export function createGitHubWebhookRoute(args: {
}
}
}
if (issueOutcome) {
await recordGitHubIssueOutcome(args.db, issueOutcome);
}
const recordedIssueConversations = issueConversations
? await recordGitHubIssueConversations(args.db, issueConversations)
: false;
const recordedPullRequestConversations = pullRequestConversations
? await recordGitHubPullRequestConversations(
args.db,
pullRequestConversations,
)
: false;
const recordedPullRequestLinkedIssues = pullRequestLinkedIssues
? await recordGitHubPullRequestLinkedIssues(
args.db,
pullRequestLinkedIssues,
)
: false;

const failingChecks =
eventName === "check_suite" && args.loadFailingChecks
Expand Down
28 changes: 25 additions & 3 deletions packages/junior-github/src/webhooks/pull-request-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ import { botLoginFromEmail } from "./ownership.js";

const canonicalPullRequestOutcomeSchema = z
.object({
action: z.enum(["opened", "closed", "reopened"]),
action: z.enum([
"opened",
"closed",
"reopened",
"ready_for_review",
"converted_to_draft",
]),
pull_request: z
.object({
body: z.string().nullable().optional(),
closed_at: z.string().nullable().optional(),
created_at: z.string(),
draft: z.boolean().default(false),
id: z.number().int().positive(),
merged: z.boolean(),
merged_at: z.string().nullable().optional(),
Expand All @@ -38,12 +45,19 @@ const canonicalPullRequestOutcomeSchema = z

const pullRequestOutcomeSchema = z
.object({
action: z.enum(["opened", "closed", "reopened"]),
action: z.enum([
"opened",
"closed",
"reopened",
"ready_for_review",
"converted_to_draft",
]),
pull_request: z
.object({
body: z.string().nullable().optional(),
closed_at: z.string().nullable().optional(),
created_at: z.string(),
draft: z.boolean().default(false),
id: z.number().int().positive(),
merged: z.boolean(),
merged_at: z.string().nullable().optional(),
Expand All @@ -67,6 +81,7 @@ const pullRequestOutcomeSchema = z
body: provider.pull_request.body,
closed_at: provider.pull_request.closed_at,
created_at: provider.pull_request.created_at,
draft: provider.pull_request.draft,
id: provider.pull_request.id,
merged: provider.pull_request.merged,
merged_at: provider.pull_request.merged_at,
Expand Down Expand Up @@ -140,7 +155,13 @@ export function normalizeGitHubPullRequestOutcome(args: {
const lifecycle = pullRequestLifecycleActionSchema.safeParse(args.body);
if (
!lifecycle.success ||
!["opened", "closed", "reopened"].includes(lifecycle.data.action)
![
"opened",
"closed",
"reopened",
"ready_for_review",
"converted_to_draft",
].includes(lifecycle.data.action)
) {
return undefined;
}
Expand Down Expand Up @@ -183,6 +204,7 @@ export function normalizeGitHubPullRequestOutcome(args: {
return {
candidateOwned,
closedAt,
draft: pullRequest.draft,
mergedAt,
number: pullRequest.number,
openedAt,
Expand Down
4 changes: 4 additions & 0 deletions packages/junior/src/api/conversations/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { conversationFeedSchema } from "../schema/conversation";
import type { ConversationFeed } from "../schema/conversation";
import { readRootConversationMetricsFromSql } from "./usage";
import { readConversationAuxiliaryCostsFromSql } from "./auxiliary-costs";
import { listLatestConversationPullRequests } from "@/chat/plugins/annotations";

const CONVERSATION_FEED_LIMIT = 50;

Expand Down Expand Up @@ -218,13 +219,15 @@ export async function readConversationFeedFromSql(
accessByConversation,
auxiliaryCostsByRoot,
metricsByRoot,
pullRequestByConversation,
teamDomainByTeamId,
] = await Promise.all([
readConversationAccessFromSql(db, conversationIds, options.viewer),
readConversationAuxiliaryCostsFromSql(db, conversationIds, {
includeDescendants: true,
}),
readRootConversationMetricsFromSql(db, conversationIds),
listLatestConversationPullRequests(db, conversationIds),
resolveSlackTeamDomains(
conversations.flatMap((conversation) =>
conversation.sessionSource?.platform === "slack"
Expand All @@ -242,6 +245,7 @@ export async function readConversationFeedFromSql(
access: accessByConversation.get(conversation.conversationId),
auxiliaryCosts: auxiliaryCostsByRoot.get(conversation.conversationId),
durationMs: metrics?.durationMs ?? row.conversation.durationMs,
pullRequest: pullRequestByConversation.get(conversation.conversationId),
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
teamDomainByTeamId,
...(row.destination?.visibility === "public"
? { locationId: row.destination.id }
Expand Down
4 changes: 4 additions & 0 deletions packages/junior/src/api/conversations/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export function conversationSummaryFromStoredConversation(args: {
conversation: ConversationProjectionSource;
durationMs: number;
locationId?: string;
pullRequest?: ConversationSummaryReport["pullRequest"];
teamDomainByTeamId?: ReadonlyMap<string, string>;
usage?: ConversationUsage;
}): ConversationSummaryReport {
Expand Down Expand Up @@ -265,6 +266,9 @@ export function conversationSummaryFromStoredConversation(args: {
...(args.auxiliaryCosts ? { auxiliaryCosts: args.auxiliaryCosts } : {}),
...(usage ? { cumulativeUsage: usage } : {}),
...(actorIdentity ? { actorIdentity } : {}),
...(canViewPrivateContent && args.pullRequest
? { pullRequest: args.pullRequest }
: {}),
...(sourceUrl ? { sourceUrl } : {}),
...(conversation.archivedAtMs
? { archivedAt: new Date(conversation.archivedAtMs).toISOString() }
Expand Down
8 changes: 8 additions & 0 deletions packages/junior/src/api/schema/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ export const conversationSummaryReportSchema = z
channelName: z.string().optional(),
channelNameRedacted: z.boolean().optional(),
locationId: z.string().optional(),
pullRequest: z
.object({
label: z.string(),
status: z.enum(["draft", "open", "merged"]),
url: z.string().url(),
})
.strict()
.optional(),
sentryTraceUrl: z.string().optional(),
sourceUrl: z.string().url().optional(),
traceId: z.string().optional(),
Expand Down
50 changes: 49 additions & 1 deletion packages/junior/src/chat/plugins/annotations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, inArray } from "drizzle-orm";
import {
conversationAnnotationInputSchema,
type ConversationAnnotation,
Expand Down Expand Up @@ -58,6 +58,54 @@ export function createPluginAnnotations(args: {
},
};
}
export type ConversationPullRequest = {
label: string;
status: "draft" | "open" | "merged";
url: string;
};

/** Return the newest GitHub pull request annotation for each conversation. */
export async function listLatestConversationPullRequests(
db: JuniorDatabase,
conversationIds: readonly string[],
): Promise<Map<string, ConversationPullRequest>> {
if (conversationIds.length === 0) return new Map();
const rows = await db
.select()
.from(juniorConversationAnnotations)
.where(
and(
inArray(juniorConversationAnnotations.conversationId, conversationIds),
eq(juniorConversationAnnotations.plugin, "github"),
eq(juniorConversationAnnotations.kind, "resource_link"),
),
)
.orderBy(
desc(juniorConversationAnnotations.createdAt),
desc(juniorConversationAnnotations.key),
);
const latest = new Map<string, ConversationPullRequest>();
for (const row of rows) {
if (latest.has(row.conversationId)) continue;
const parsed = conversationAnnotationInputSchema.safeParse(row.annotation);
if (
!parsed.success ||
parsed.data.kind !== "resource_link" ||
!parsed.data.url.startsWith("https://github.com/") ||
!parsed.data.url.includes("/pull/") ||
!["draft", "open", "merged"].includes(parsed.data.status ?? "")
) {
continue;
}
latest.set(row.conversationId, {
label: parsed.data.label,
status: parsed.data.status as ConversationPullRequest["status"],
url: parsed.data.url,
});
}
return latest;
}

export async function listConversationAnnotations(
db: JuniorDatabase,
conversationId: string,
Expand Down
Loading
Loading