fix(collections): surface pull requests from channels

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-08-27 18:01:27 -04:00
parent 972d73dd9f
commit 2ba87e4993
5 changed files with 100 additions and 30 deletions

View File

@@ -229,6 +229,52 @@ test("suppresses matching PR mention rows across sources but keeps unrelated cha
);
});
test("suppresses a PR conversation without hiding another thread from the same channel source", () => {
const message = (eventId, threadRootId, label) => ({
activityType: "channel-message",
actorIdentity: "buzz:human",
authorPubkey: "human",
channelId: "channel-1",
createdAt: 1,
eventId,
kind: "thread",
label,
sourceMemberId: "channel-source",
threadRootId,
url: "",
});
const prConversation = message("pr-reply", "pr-thread", "PR chatter");
const unrelatedConversation = message(
"design-reply",
"design-thread",
"Unrelated design decision",
);
const pullRequest = {
...prConversation,
activityType: "github-pr",
kind: "pull request",
label: "PR #7",
sourceEventIds: ["pr-reply"],
sourceMemberIds: ["channel-source"],
url: "https://github.com/block/buzz/pull/7",
};
const stateChange = {
...pullRequest,
activityType: "github-pr-activity",
createdAt: 2,
kind: "PR review",
url: `${pullRequest.url}#pullrequestreview-1`,
};
assert.deepEqual(
projectCollectionActivity(
[prConversation, unrelatedConversation, pullRequest, stateChange],
{ human: false },
),
[unrelatedConversation, stateChange],
);
});
test("names channel-derived live thread provenance", () => {
assert.equal(
collectionChannelThreadProvenance("#design"),

View File

@@ -362,15 +362,22 @@ export function projectCollectionActivity(
activity: readonly DerivedCollectionActivity[],
actorIsAgent: Readonly<Record<string, boolean | undefined>>,
): DerivedCollectionActivity[] {
const conversationIdentity = (item: DerivedCollectionActivity) =>
item.channelId
? `${item.channelId}:${item.threadRootId ?? item.eventId ?? item.sourceMemberId}`
: null;
const pullRequestUrls = new Set(
activity
.filter((item) => item.activityType === "github-pr")
.map((item) => item.url.toLocaleLowerCase()),
);
const pullRequestSources = new Set(
const pullRequestConversations = new Set(
activity
.filter((item) => item.activityType === "github-pr")
.flatMap((item) => item.sourceMemberIds ?? [item.sourceMemberId]),
.flatMap((item) => {
const identity = conversationIdentity(item);
return identity ? [identity] : [];
}),
);
const pullRequestEvents = new Set(
activity
@@ -384,7 +391,7 @@ export function projectCollectionActivity(
]).some((mention) => pullRequestUrls.has(mention.url.toLocaleLowerCase()));
return (
!mentionsResolvedPullRequest &&
!pullRequestSources.has(item.sourceMemberId) &&
!pullRequestConversations.has(conversationIdentity(item) ?? "") &&
(!item.eventId || !pullRequestEvents.has(item.eventId))
);
});

View File

@@ -67,7 +67,6 @@ function sourceLabel(member: CollectionMember): string {
function messageActivity(
member: CollectionMember,
events: Awaited<ReturnType<typeof getChannelWindowEvents>>,
includePullRequests: boolean,
): DerivedCollectionActivity[] {
const reference = member.reference;
if (
@@ -102,25 +101,21 @@ function messageActivity(
reference.type === "thread" ? reference.root_event_id : thread.rootId,
url: "",
};
const pullRequests = includePullRequests
? extractGitHubPullRequestLinks([event]).map(
(link): DerivedCollectionActivity => ({
...link,
activityType: "github-pr",
actorIdentity: `buzz:${event.pubkey.toLocaleLowerCase()}`,
authorPubkey: event.pubkey,
channelId,
createdAt: event.created_at,
eventId: event.id,
provenanceLabel: `${sourceLabel(member)} → mentioned PR`,
sourceMemberId: member.id,
threadRootId:
reference.type === "thread"
? reference.root_event_id
: thread.rootId,
}),
)
: [];
const pullRequests = extractGitHubPullRequestLinks([event]).map(
(link): DerivedCollectionActivity => ({
...link,
activityType: "github-pr",
actorIdentity: `buzz:${event.pubkey.toLocaleLowerCase()}`,
authorPubkey: event.pubkey,
channelId,
createdAt: event.created_at,
eventId: event.id,
provenanceLabel: `${sourceLabel(member)} → mentioned PR`,
sourceMemberId: member.id,
threadRootId:
reference.type === "thread" ? reference.root_event_id : thread.rootId,
}),
);
return [message, ...pullRequests];
});
}
@@ -332,7 +327,7 @@ async function discoverCollectionMemberActivity(
},
);
return {
items: [...threadActivity, ...messageActivity(member, messages, false)],
items: [...threadActivity, ...messageActivity(member, messages)],
warnings: [],
};
}
@@ -344,14 +339,14 @@ async function discoverCollectionMemberActivity(
{ limit: COLLECTION_CHANNEL_DISCOVERY_LIMIT },
);
return {
items: messageActivity(member, [root, ...replies.events], true),
items: messageActivity(member, [root, ...replies.events]),
warnings: [],
};
}
if (member.reference.type === "message") {
const event = await getEventById(member.reference.event_id);
return {
items: messageActivity(member, [event], true),
items: messageActivity(member, [event]),
warnings: [],
};
}

View File

@@ -52,6 +52,8 @@ import {
CollectionRowActions,
DerivedCollectionActivityRow,
} from "./CollectionDetailRows";
const PEOPLE_PREVIEW_LIMIT = 8;
import { CollectionGlyph } from "./CollectionGlyph";
import { CollectionIconDialog } from "./CollectionIconDialog";
@@ -98,6 +100,12 @@ export function CollectionDetailScreen({
!identity.startsWith("buzz:") ||
!profiles[identity.slice("buzz:".length)]?.isAgent,
);
const visiblePeopleIdentities = peopleIdentities.slice(
0,
PEOPLE_PREVIEW_LIMIT,
);
const hiddenPeopleCount =
peopleIdentities.length - visiblePeopleIdentities.length;
const members = (data?.members ?? []).filter((member) =>
collectionMemberMatches(member, search, type),
);
@@ -454,7 +462,7 @@ export function CollectionDetailScreen({
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{peopleIdentities.map((identity) => {
{visiblePeopleIdentities.map((identity) => {
const pubkey = identity.startsWith("buzz:")
? identity.slice("buzz:".length)
: null;
@@ -494,6 +502,11 @@ export function CollectionDetailScreen({
</button>
);
})}
{hiddenPeopleCount > 0 ? (
<span className="px-1 text-sm text-muted-foreground">
+{hiddenPeopleCount} more
</span>
) : null}
{selectedPerson ? (
<Button
onClick={() => setSelectedPerson(null)}

View File

@@ -218,10 +218,10 @@ test("creates a collection and adds supported reference types", async ({
});
}, githubPullRequestUrl);
await page.evaluate(
({ failingUrl, url }) => {
({ failingUrl }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `Thread status for Bird Voice: ${url} and ${failingUrl}`,
content: `Thread status for Bird Voice: ${failingUrl}`,
parentEventId: "mock-general-welcome",
extraTags: [
["e", "mock-general-welcome", "", "root"],
@@ -229,8 +229,14 @@ test("creates a collection and adds supported reference types", async ({
],
});
},
{ failingUrl: failingGithubPullRequestUrl, url: githubPullRequestUrl },
{ failingUrl: failingGithubPullRequestUrl },
);
await page.evaluate(() => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: "Unrelated channel update",
});
});
await page.getByRole("button", { name: "Add to collection" }).click();
await page
.getByTestId("add-to-collection-dialog")
@@ -304,6 +310,9 @@ test("creates a collection and adds supported reference types", async ({
await expect(page.getByTestId("collection-activity")).not.toContainText(
"Please review",
);
await expect(page.getByTestId("collection-activity")).toContainText(
"Unrelated channel update",
);
await expect(pullRequest.getByLabel(/^Derived from /)).toBeVisible();
await expect(pullRequest).toContainText("Live collections activity feed");
await expect(pullRequest).toContainText("open by mock-author");