From 851f92ab941611d678319af15c88043766000e9d Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:08:25 +0000 Subject: [PATCH 1/3] [Improve] Collapse unmatched analytics task authors into Automations --- .../analytics-task-user-dimension.test.ts | 133 +++++++++++++++++ .../server/analytics-task-user-dimension.ts | 138 ++++++++++++++++++ apps/web/src/lib/server/analytics.ts | 115 +-------------- 3 files changed, 272 insertions(+), 114 deletions(-) create mode 100644 apps/web/src/lib/server/analytics-task-user-dimension.test.ts create mode 100644 apps/web/src/lib/server/analytics-task-user-dimension.ts diff --git a/apps/web/src/lib/server/analytics-task-user-dimension.test.ts b/apps/web/src/lib/server/analytics-task-user-dimension.test.ts new file mode 100644 index 00000000..3d952d3e --- /dev/null +++ b/apps/web/src/lib/server/analytics-task-user-dimension.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { + AUTOMATIONS_USER_DIMENSION_KEY, + AUTOMATIONS_USER_DIMENSION_LABEL, + getCanonicalTaskAttributionDimensionValue, +} from './analytics-task-user-dimension'; + +describe('getCanonicalTaskAttributionDimensionValue', () => { + it('keeps matched users as their own series', () => { + expect( + getCanonicalTaskAttributionDimensionValue({ + attributionKind: 'matched_user', + attributedUserId: 'user-1', + attributionSourceKind: 'web', + attributionSourceDisplayName: null, + attributionSourceExternalId: null, + attributedGithubLogin: null, + effectiveAuthorKind: 'human', + effectiveAuthorUserId: 'user-1', + effectiveAuthorDisplayName: 'Matt Rubens', + effectiveAuthorGithubLogin: 'mrubens', + userName: 'Matt Rubens', + userEmail: 'matt@example.com', + }), + ).toEqual({ + key: 'user:user-1', + label: 'Matt Rubens', + disambiguationLabel: 'Matt Rubens (matt@example.com)', + }); + }); + + it('lumps unlinked external identities into Automations', () => { + expect( + getCanonicalTaskAttributionDimensionValue({ + attributionKind: 'unlinked_user', + attributedUserId: null, + attributionSourceKind: 'github', + attributionSourceDisplayName: 'openmote[bot]', + attributionSourceExternalId: 'openmote[bot]', + attributedGithubLogin: 'openmote[bot]', + effectiveAuthorKind: null, + effectiveAuthorUserId: null, + effectiveAuthorDisplayName: null, + effectiveAuthorGithubLogin: null, + userName: null, + userEmail: null, + }), + ).toEqual({ + key: AUTOMATIONS_USER_DIMENSION_KEY, + label: AUTOMATIONS_USER_DIMENSION_LABEL, + }); + }); + + it('lumps automatic/system authors into Automations', () => { + expect( + getCanonicalTaskAttributionDimensionValue({ + attributionKind: 'automatic', + attributedUserId: null, + attributionSourceKind: 'automation', + attributionSourceDisplayName: null, + attributionSourceExternalId: null, + attributedGithubLogin: null, + effectiveAuthorKind: 'roomote', + effectiveAuthorUserId: null, + effectiveAuthorDisplayName: null, + effectiveAuthorGithubLogin: null, + userName: null, + userEmail: null, + }), + ).toEqual({ + key: AUTOMATIONS_USER_DIMENSION_KEY, + label: AUTOMATIONS_USER_DIMENSION_LABEL, + }); + }); + + it('keys matched users on effective author when it differs from attributed', () => { + expect( + getCanonicalTaskAttributionDimensionValue({ + attributionKind: 'matched_user', + attributedUserId: 'attributed-1', + attributionSourceKind: 'github', + attributionSourceDisplayName: null, + attributionSourceExternalId: null, + attributedGithubLogin: 'someone', + effectiveAuthorKind: 'human', + effectiveAuthorUserId: 'effective-1', + effectiveAuthorDisplayName: 'Effective Person', + effectiveAuthorGithubLogin: 'effective', + userName: 'Attributed Person', + userEmail: 'attributed@example.com', + }), + ).toEqual({ + key: 'user:effective-1', + label: 'Effective Person', + }); + }); + + it('merges distinct non-matched sources into the same Automations key', () => { + const bot = getCanonicalTaskAttributionDimensionValue({ + attributionKind: 'unlinked_user', + attributedUserId: null, + attributionSourceKind: 'github', + attributionSourceDisplayName: 'openmote[bot]', + attributionSourceExternalId: 'openmote[bot]', + attributedGithubLogin: 'openmote[bot]', + effectiveAuthorKind: null, + effectiveAuthorUserId: null, + effectiveAuthorDisplayName: null, + effectiveAuthorGithubLogin: null, + userName: null, + userEmail: null, + }); + const roomote = getCanonicalTaskAttributionDimensionValue({ + attributionKind: 'automatic', + attributedUserId: null, + attributionSourceKind: 'system', + attributionSourceDisplayName: null, + attributionSourceExternalId: null, + attributedGithubLogin: null, + effectiveAuthorKind: 'roomote', + effectiveAuthorUserId: null, + effectiveAuthorDisplayName: null, + effectiveAuthorGithubLogin: null, + userName: null, + userEmail: null, + }); + + expect(bot.key).toBe(AUTOMATIONS_USER_DIMENSION_KEY); + expect(roomote.key).toBe(AUTOMATIONS_USER_DIMENSION_KEY); + expect(bot.key).toBe(roomote.key); + }); +}); diff --git a/apps/web/src/lib/server/analytics-task-user-dimension.ts b/apps/web/src/lib/server/analytics-task-user-dimension.ts new file mode 100644 index 00000000..ccaf9444 --- /dev/null +++ b/apps/web/src/lib/server/analytics-task-user-dimension.ts @@ -0,0 +1,138 @@ +import { + type EffectiveAuthorKind, + PRODUCT_NAME, + type TaskAttributionKind, + type TaskAttributionSourceKind, +} from '@roomote/types'; +import { resolveTaskAttributionDisplay } from '@roomote/db/server'; +import { getUserDisplayName } from '@/lib/user-display-name'; + +export const AUTOMATIONS_USER_DIMENSION_KEY = 'automations'; +export const AUTOMATIONS_USER_DIMENSION_LABEL = 'Automations'; + +export type AnalyticsUserDimensionValue = { + key: string; + label: string; + disambiguationLabel?: string; +}; + +function formatUserLabel( + user: { name: string | null; email: string | null } | null, +) { + return getUserDisplayName(user) || PRODUCT_NAME; +} + +function formatDisambiguatedUserLabel( + user: { name: string | null; email: string | null } | null, +) { + const displayName = getUserDisplayName(user); + + if (displayName && user?.email) { + return `${displayName} (${user.email})`; + } + + return formatUserLabel(user); +} + +function createDimensionValue( + key: string, + label: string, + disambiguationLabel?: string, +): AnalyticsUserDimensionValue { + return { + key, + label, + ...(disambiguationLabel && + disambiguationLabel !== label && + disambiguationLabel.trim() + ? { disambiguationLabel } + : {}), + }; +} + +function getCanonicalUserDimensionValue(user: { + id: string | null; + name: string | null; + email: string | null; +}): AnalyticsUserDimensionValue { + const label = formatUserLabel(user); + const normalizedEmail = user.email?.trim().toLowerCase() ?? null; + const key = user.id + ? `user:${user.id}` + : normalizedEmail + ? `email:${normalizedEmail}` + : PRODUCT_NAME; + + return createDimensionValue(key, label, formatDisambiguatedUserLabel(user)); +} + +/** + * Canonical "By User" dimension for task analytics. + * Matched product users keep their own series; every other attribution + * (unlinked external identities, bots, automatic/system authors) collapses + * into a single Automations series. + */ +export function getCanonicalTaskAttributionDimensionValue(input: { + attributionKind: TaskAttributionKind | null; + attributedUserId: string | null; + attributionSourceKind: TaskAttributionSourceKind | null; + attributionSourceDisplayName: string | null; + attributionSourceExternalId: string | null; + attributedGithubLogin: string | null; + effectiveAuthorKind: EffectiveAuthorKind | null; + effectiveAuthorUserId: string | null; + effectiveAuthorDisplayName: string | null; + effectiveAuthorGithubLogin: string | null; + userName: string | null; + userEmail: string | null; +}): AnalyticsUserDimensionValue { + const attribution = resolveTaskAttributionDisplay( + { + attributionKind: input.attributionKind, + attributedUserId: input.attributedUserId, + attributionSourceKind: input.attributionSourceKind, + attributionSourceDisplayName: input.attributionSourceDisplayName, + attributionSourceExternalId: input.attributionSourceExternalId, + attributedGithubLogin: input.attributedGithubLogin, + effectiveAuthorKind: input.effectiveAuthorKind, + effectiveAuthorUserId: input.effectiveAuthorUserId, + effectiveAuthorDisplayName: input.effectiveAuthorDisplayName, + effectiveAuthorGithubLogin: input.effectiveAuthorGithubLogin, + }, + { + attributedUser: { + id: input.attributedUserId, + name: input.userName, + email: input.userEmail, + }, + }, + ); + + if (attribution.kind === 'matched_user') { + const matchedUserId = + input.effectiveAuthorKind === 'human' && input.effectiveAuthorUserId + ? input.effectiveAuthorUserId + : input.attributedUserId; + const matchedUserName = + input.effectiveAuthorKind === 'human' && + input.effectiveAuthorDisplayName && + input.effectiveAuthorUserId !== input.attributedUserId + ? input.effectiveAuthorDisplayName + : input.userName; + const matchedUserEmail = + matchedUserId && matchedUserId === input.attributedUserId + ? input.userEmail + : null; + + return getCanonicalUserDimensionValue({ + id: matchedUserId, + name: matchedUserName, + email: matchedUserEmail, + }); + } + + return createDimensionValue( + AUTOMATIONS_USER_DIMENSION_KEY, + AUTOMATIONS_USER_DIMENSION_LABEL, + ); +} diff --git a/apps/web/src/lib/server/analytics.ts b/apps/web/src/lib/server/analytics.ts index 9a263bd9..762417a7 100644 --- a/apps/web/src/lib/server/analytics.ts +++ b/apps/web/src/lib/server/analytics.ts @@ -17,11 +17,8 @@ import { syncGitHubPullRequestFactsForOrg } from '@roomote/sdk/server'; import { ALL_REPOSITORIES, CloudTaskType, - type EffectiveAuthorKind, type PullRequestStatus, PRODUCT_NAME, - type TaskAttributionKind, - type TaskAttributionSourceKind, } from '@roomote/types'; import { db, @@ -33,7 +30,6 @@ import { desc, eq, inArray, - resolveTaskAttributionDisplay, } from '@roomote/db/server'; import { @@ -61,8 +57,8 @@ import { } from '@/types'; import type { UserAuthSuccess } from '@/types'; -import { getUserDisplayName } from '@/lib/user-display-name'; +import { getCanonicalTaskAttributionDimensionValue } from './analytics-task-user-dimension'; import { getLatestCloudJobsByTaskId } from './cloud-jobs'; import { getRepositories } from './source-control'; import { @@ -291,24 +287,6 @@ function getSummaryPeriodCount( } } -function formatUserLabel( - user: { name: string | null; email: string | null } | null, -) { - return getUserDisplayName(user) || PRODUCT_NAME; -} - -function formatDisambiguatedUserLabel( - user: { name: string | null; email: string | null } | null, -) { - const displayName = getUserDisplayName(user); - - if (displayName && user?.email) { - return `${displayName} (${user.email})`; - } - - return formatUserLabel(user); -} - function createDimensionValue( key: string, label: string, @@ -329,97 +307,6 @@ function createLabelBackedDimensionValue(label: string) { return createDimensionValue(label, label); } -function getCanonicalUserDimensionValue(user: { - id: string | null; - name: string | null; - email: string | null; -}) { - const label = formatUserLabel(user); - const normalizedEmail = user.email?.trim().toLowerCase() ?? null; - const key = user.id - ? `user:${user.id}` - : normalizedEmail - ? `email:${normalizedEmail}` - : PRODUCT_NAME; - - return createDimensionValue(key, label, formatDisambiguatedUserLabel(user)); -} - -function getCanonicalTaskAttributionDimensionValue(input: { - attributionKind: TaskAttributionKind | null; - attributedUserId: string | null; - attributionSourceKind: TaskAttributionSourceKind | null; - attributionSourceDisplayName: string | null; - attributionSourceExternalId: string | null; - attributedGithubLogin: string | null; - effectiveAuthorKind: EffectiveAuthorKind | null; - effectiveAuthorUserId: string | null; - effectiveAuthorDisplayName: string | null; - effectiveAuthorGithubLogin: string | null; - userName: string | null; - userEmail: string | null; -}) { - const attribution = resolveTaskAttributionDisplay( - { - attributionKind: input.attributionKind, - attributedUserId: input.attributedUserId, - attributionSourceKind: input.attributionSourceKind, - attributionSourceDisplayName: input.attributionSourceDisplayName, - attributionSourceExternalId: input.attributionSourceExternalId, - attributedGithubLogin: input.attributedGithubLogin, - effectiveAuthorKind: input.effectiveAuthorKind, - effectiveAuthorUserId: input.effectiveAuthorUserId, - effectiveAuthorDisplayName: input.effectiveAuthorDisplayName, - effectiveAuthorGithubLogin: input.effectiveAuthorGithubLogin, - }, - { - attributedUser: { - id: input.attributedUserId, - name: input.userName, - email: input.userEmail, - }, - }, - ); - - if (attribution.kind === 'matched_user') { - const matchedUserId = - input.effectiveAuthorKind === 'human' && input.effectiveAuthorUserId - ? input.effectiveAuthorUserId - : input.attributedUserId; - const matchedUserName = - input.effectiveAuthorKind === 'human' && - input.effectiveAuthorDisplayName && - input.effectiveAuthorUserId !== input.attributedUserId - ? input.effectiveAuthorDisplayName - : input.userName; - const matchedUserEmail = - matchedUserId && matchedUserId === input.attributedUserId - ? input.userEmail - : null; - - return getCanonicalUserDimensionValue({ - id: matchedUserId, - name: matchedUserName, - email: matchedUserEmail, - }); - } - - if (attribution.kind === 'unlinked_user') { - const key = - input.effectiveAuthorKind === 'human' && - !input.effectiveAuthorUserId && - attribution.analyticsDisplay - ? `unlinked:${attribution.sourceKind}:${attribution.analyticsDisplay}` - : input.attributionSourceExternalId - ? `unlinked:${input.attributionSourceKind}:${input.attributionSourceExternalId}` - : `unlinked:${attribution.analyticsDisplay}`; - - return createDimensionValue(key, attribution.analyticsDisplay); - } - - return createLabelBackedDimensionValue(attribution.analyticsDisplay); -} - function formatRepositoryLabel(repositoryName: string) { return repositoryName === ALL_REPOSITORIES ? ALL_REPOS_LABEL : repositoryName; } From 5f95c017d8bbbd6d38c52c15d648e98c54dd72d0 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:09:45 +0000 Subject: [PATCH 2/3] [Chore] Drop unused exported analytics dimension type for knip --- apps/web/src/lib/server/analytics-task-user-dimension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/analytics-task-user-dimension.ts b/apps/web/src/lib/server/analytics-task-user-dimension.ts index ccaf9444..51bbfcfd 100644 --- a/apps/web/src/lib/server/analytics-task-user-dimension.ts +++ b/apps/web/src/lib/server/analytics-task-user-dimension.ts @@ -10,7 +10,7 @@ import { getUserDisplayName } from '@/lib/user-display-name'; export const AUTOMATIONS_USER_DIMENSION_KEY = 'automations'; export const AUTOMATIONS_USER_DIMENSION_LABEL = 'Automations'; -export type AnalyticsUserDimensionValue = { +type AnalyticsUserDimensionValue = { key: string; label: string; disambiguationLabel?: string; From 5cd17f917139d7557b40e99c7dea9146fa899bb5 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:20:27 +0000 Subject: [PATCH 3/3] [Improve] Attribute tasks to user or named automation Stamp and surface specific automation names (PR Reviewer, Suggest Ideas, etc.) for product-owned launches so analytics and task display are not limited to a generic Automations/Roomote bucket. --- .../analytics-task-user-dimension.test.ts | 122 ++++++++++-------- .../server/analytics-task-user-dimension.ts | 37 +++++- .../src/server/cloud-job-queue.ts | 8 +- packages/db/src/lib/task-attribution.test.ts | 62 +++++++++ packages/db/src/lib/task-attribution.ts | 45 ++++++- packages/types/src/cloud-jobs.ts | 2 + packages/types/src/index.ts | 1 + packages/types/src/task-automation-display.ts | 79 ++++++++++++ 8 files changed, 286 insertions(+), 70 deletions(-) create mode 100644 packages/types/src/task-automation-display.ts diff --git a/apps/web/src/lib/server/analytics-task-user-dimension.test.ts b/apps/web/src/lib/server/analytics-task-user-dimension.test.ts index 3d952d3e..3e0af9ce 100644 --- a/apps/web/src/lib/server/analytics-task-user-dimension.test.ts +++ b/apps/web/src/lib/server/analytics-task-user-dimension.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it } from 'vitest'; +import { + CloudTaskType, + PRODUCT_NAME, + resolveTaskAutomationDisplayName, +} from '@roomote/types'; import { AUTOMATIONS_USER_DIMENSION_KEY, @@ -30,15 +35,15 @@ describe('getCanonicalTaskAttributionDimensionValue', () => { }); }); - it('lumps unlinked external identities into Automations', () => { + it('uses named automation series for automatic attribution', () => { expect( getCanonicalTaskAttributionDimensionValue({ - attributionKind: 'unlinked_user', + attributionKind: 'automatic', attributedUserId: null, - attributionSourceKind: 'github', - attributionSourceDisplayName: 'openmote[bot]', - attributionSourceExternalId: 'openmote[bot]', - attributedGithubLogin: 'openmote[bot]', + attributionSourceKind: 'automation', + attributionSourceDisplayName: 'PR Reviewer', + attributionSourceExternalId: null, + attributedGithubLogin: null, effectiveAuthorKind: null, effectiveAuthorUserId: null, effectiveAuthorDisplayName: null, @@ -47,17 +52,17 @@ describe('getCanonicalTaskAttributionDimensionValue', () => { userEmail: null, }), ).toEqual({ - key: AUTOMATIONS_USER_DIMENSION_KEY, - label: AUTOMATIONS_USER_DIMENSION_LABEL, + key: 'automation:PR Reviewer', + label: 'PR Reviewer', }); }); - it('lumps automatic/system authors into Automations', () => { + it('falls back to Automations when automatic has no specific name', () => { expect( getCanonicalTaskAttributionDimensionValue({ attributionKind: 'automatic', attributedUserId: null, - attributionSourceKind: 'automation', + attributionSourceKind: 'system', attributionSourceDisplayName: null, attributionSourceExternalId: null, attributedGithubLogin: null, @@ -74,60 +79,65 @@ describe('getCanonicalTaskAttributionDimensionValue', () => { }); }); - it('keys matched users on effective author when it differs from attributed', () => { + it('keeps unlinked external identities distinct', () => { expect( getCanonicalTaskAttributionDimensionValue({ - attributionKind: 'matched_user', - attributedUserId: 'attributed-1', + attributionKind: 'unlinked_user', + attributedUserId: null, attributionSourceKind: 'github', - attributionSourceDisplayName: null, - attributionSourceExternalId: null, - attributedGithubLogin: 'someone', - effectiveAuthorKind: 'human', - effectiveAuthorUserId: 'effective-1', - effectiveAuthorDisplayName: 'Effective Person', - effectiveAuthorGithubLogin: 'effective', - userName: 'Attributed Person', - userEmail: 'attributed@example.com', + attributionSourceDisplayName: 'octocat', + attributionSourceExternalId: 'octocat', + attributedGithubLogin: 'octocat', + effectiveAuthorKind: null, + effectiveAuthorUserId: null, + effectiveAuthorDisplayName: null, + effectiveAuthorGithubLogin: null, + userName: null, + userEmail: null, }), ).toEqual({ - key: 'user:effective-1', - label: 'Effective Person', + key: 'unlinked:github:octocat', + label: 'octocat', }); }); +}); - it('merges distinct non-matched sources into the same Automations key', () => { - const bot = getCanonicalTaskAttributionDimensionValue({ - attributionKind: 'unlinked_user', - attributedUserId: null, - attributionSourceKind: 'github', - attributionSourceDisplayName: 'openmote[bot]', - attributionSourceExternalId: 'openmote[bot]', - attributedGithubLogin: 'openmote[bot]', - effectiveAuthorKind: null, - effectiveAuthorUserId: null, - effectiveAuthorDisplayName: null, - effectiveAuthorGithubLogin: null, - userName: null, - userEmail: null, - }); - const roomote = getCanonicalTaskAttributionDimensionValue({ - attributionKind: 'automatic', - attributedUserId: null, - attributionSourceKind: 'system', - attributionSourceDisplayName: null, - attributionSourceExternalId: null, - attributedGithubLogin: null, - effectiveAuthorKind: 'roomote', - effectiveAuthorUserId: null, - effectiveAuthorDisplayName: null, - effectiveAuthorGithubLogin: null, - userName: null, - userEmail: null, - }); +describe('resolveTaskAutomationDisplayName', () => { + it('names PR review and Conflict resolver automations', () => { + expect( + resolveTaskAutomationDisplayName({ + type: CloudTaskType.GithubPrReview, + }), + ).toBe('PR Reviewer'); + expect( + resolveTaskAutomationDisplayName({ + type: CloudTaskType.GithubPrConflictResolve, + }), + ).toBe('Resolve PR Conflicts'); + }); - expect(bot.key).toBe(AUTOMATIONS_USER_DIMENSION_KEY); - expect(roomote.key).toBe(AUTOMATIONS_USER_DIMENSION_KEY); - expect(bot.key).toBe(roomote.key); + it('names scheduled suggestion automations from suggestionSource', () => { + expect( + resolveTaskAutomationDisplayName({ + type: CloudTaskType.SuggestedTasks, + payload: { suggestionSource: 'suggest_ideas' }, + }), + ).toBe('Suggest Ideas'); + expect( + resolveTaskAutomationDisplayName({ + type: CloudTaskType.SuggestedTasks, + payload: { suggestionSource: 'dependabot_triage' }, + }), + ).toBe('Triage Dependabot Alerts'); + }); + + it('returns null when no automation identity is available', () => { + expect( + resolveTaskAutomationDisplayName({ + type: CloudTaskType.StandardTask, + payload: { repo: 'owner/repo' } as never, + }), + ).toBeNull(); + expect(PRODUCT_NAME).toBeTruthy(); }); }); diff --git a/apps/web/src/lib/server/analytics-task-user-dimension.ts b/apps/web/src/lib/server/analytics-task-user-dimension.ts index 51bbfcfd..6e5574a0 100644 --- a/apps/web/src/lib/server/analytics-task-user-dimension.ts +++ b/apps/web/src/lib/server/analytics-task-user-dimension.ts @@ -68,9 +68,10 @@ function getCanonicalUserDimensionValue(user: { /** * Canonical "By User" dimension for task analytics. - * Matched product users keep their own series; every other attribution - * (unlinked external identities, bots, automatic/system authors) collapses - * into a single Automations series. + * Matched product users keep their own series. Named automations each get + * their series (e.g. "PR Reviewer", "Suggest Ideas"). Other automatic work + * falls back to a shared Automations series. Residual unlinked identities + * keep their own label so they are not mislabeled as an automation. */ export function getCanonicalTaskAttributionDimensionValue(input: { attributionKind: TaskAttributionKind | null; @@ -131,8 +132,30 @@ export function getCanonicalTaskAttributionDimensionValue(input: { }); } - return createDimensionValue( - AUTOMATIONS_USER_DIMENSION_KEY, - AUTOMATIONS_USER_DIMENSION_LABEL, - ); + if (attribution.kind === 'automatic') { + const label = attribution.analyticsDisplay?.trim() || PRODUCT_NAME; + if ( + !label || + label === PRODUCT_NAME || + label === AUTOMATIONS_USER_DIMENSION_LABEL + ) { + return createDimensionValue( + AUTOMATIONS_USER_DIMENSION_KEY, + AUTOMATIONS_USER_DIMENSION_LABEL, + ); + } + + return createDimensionValue(`automation:${label}`, label); + } + + // Unlinked external identities still show their own label. + const unlinkedLabel = + attribution.analyticsDisplay?.trim() || AUTOMATIONS_USER_DIMENSION_LABEL; + const unlinkedKey = + input.attributionSourceExternalId != null && + input.attributionSourceExternalId.trim() + ? `unlinked:${input.attributionSourceKind}:${input.attributionSourceExternalId}` + : `unlinked:${unlinkedLabel}`; + + return createDimensionValue(unlinkedKey, unlinkedLabel); } diff --git a/packages/cloud-agents/src/server/cloud-job-queue.ts b/packages/cloud-agents/src/server/cloud-job-queue.ts index cfea38db..16b58646 100644 --- a/packages/cloud-agents/src/server/cloud-job-queue.ts +++ b/packages/cloud-agents/src/server/cloud-job-queue.ts @@ -28,6 +28,7 @@ import { resolveSourceControlProviderFromPayload, TASK_TIMEOUT_MS, type SourceControlProvider, + resolveTaskAutomationDisplayName, } from '@roomote/types'; import { Env } from '@roomote/env'; import { @@ -127,12 +128,17 @@ async function resolvePersistedTaskAttributionSnapshot( task: CloudTask, ): Promise { if (task.attributionOverride?.kind === 'automatic') { + const overrideDisplayName = + typeof task.attributionOverride.displayName === 'string' + ? task.attributionOverride.displayName.trim() + : ''; return { attributionKind: 'automatic', attributedUserId: null, attributionSourceKind: task.attributionOverride.sourceKind ?? 'automation', - attributionSourceDisplayName: null, + attributionSourceDisplayName: + overrideDisplayName || resolveTaskAutomationDisplayName(task) || null, attributionSourceExternalId: null, attributedGithubLogin: null, attributedGithubUserId: null, diff --git a/packages/db/src/lib/task-attribution.test.ts b/packages/db/src/lib/task-attribution.test.ts index 7baea96b..47284e22 100644 --- a/packages/db/src/lib/task-attribution.test.ts +++ b/packages/db/src/lib/task-attribution.test.ts @@ -222,4 +222,66 @@ describe('buildTaskAttributionSnapshot', () => { attributedGithubUserId: 12345, }); }); + + it('attributes GitHub PR review background tasks to the PR Reviewer automation', async () => { + const snapshot = await buildTaskAttributionSnapshot(db, { + userId: null, + type: CloudTaskType.GithubPrReview, + githubLogin: 'openmote[bot]', + githubUserId: 1, + payload: { + repo: 'owner/repo', + prNumber: 1, + prTitle: 'Test', + prUrl: 'https://example.com/pr/1', + headSha: 'abc', + branchName: 'feature', + }, + } satisfies CloudTask); + + expect(snapshot).toMatchObject({ + attributionKind: 'automatic', + attributedUserId: null, + attributionSourceKind: 'automation', + attributionSourceDisplayName: 'PR Reviewer', + }); + }); + + it('attributes scheduled suggestion tasks to the matching automation label', async () => { + const snapshot = await buildTaskAttributionSnapshot(db, { + userId: null, + type: CloudTaskType.SuggestedTasks, + payload: { + repo: 'owner/repo', + description: 'suggest', + suggestionSource: 'suggest_ideas', + }, + } satisfies CloudTask); + + expect(snapshot).toMatchObject({ + attributionKind: 'automatic', + attributionSourceKind: 'automation', + attributionSourceDisplayName: 'Suggest Ideas', + }); + }); +}); + +describe('resolveTaskAttributionDisplay with automation names', () => { + it('surfaces automatic display names instead of the product brand', () => { + expect( + resolveTaskAttributionDisplay({ + attributionKind: 'automatic', + attributedUserId: null, + attributionSourceKind: 'automation', + attributionSourceDisplayName: 'PR Reviewer', + attributionSourceExternalId: null, + attributedGithubLogin: null, + attributedGithubUserId: null, + }), + ).toMatchObject({ + kind: 'automatic', + productDisplay: 'PR Reviewer', + analyticsDisplay: 'PR Reviewer', + }); + }); }); diff --git a/packages/db/src/lib/task-attribution.ts b/packages/db/src/lib/task-attribution.ts index 51f5bbc7..51cbff11 100644 --- a/packages/db/src/lib/task-attribution.ts +++ b/packages/db/src/lib/task-attribution.ts @@ -10,6 +10,8 @@ import { PRODUCT_NAME, getCommunicationProviderFromTaskPayload, getUserDisplayName, + isKnownAutomationTaskType, + resolveTaskAutomationDisplayName, } from '@roomote/types'; import { and, desc, eq } from 'drizzle-orm'; @@ -152,13 +154,16 @@ export function resolveTaskAttributionDisplay( ); if (effectiveAuthorKind === 'roomote') { + const automationDisplay = + normalizeNullableString(snapshot.attributionSourceDisplayName) || + PRODUCT_NAME; return { authorKind: 'roomote', kind: 'automatic', sourceKind: snapshot.attributionSourceKind ?? 'system', githubDisplay: null, - productDisplay: PRODUCT_NAME, - analyticsDisplay: PRODUCT_NAME, + productDisplay: automationDisplay, + analyticsDisplay: automationDisplay, assigneeGithubLogin: effectivePrOwnerKind === 'specific_user' ? effectivePrOwnerGithubLogin @@ -222,13 +227,17 @@ export function resolveTaskAttributionDisplay( }; } + const automationDisplay = + normalizeNullableString(snapshot.attributionSourceDisplayName) || + PRODUCT_NAME; + return { authorKind: 'roomote', kind: 'automatic', sourceKind, githubDisplay: null, - productDisplay: PRODUCT_NAME, - analyticsDisplay: PRODUCT_NAME, + productDisplay: automationDisplay, + analyticsDisplay: automationDisplay, assigneeGithubLogin: null, }; } @@ -512,11 +521,32 @@ export async function buildTaskAttributionSnapshot( const sourceKind = inferSourceKind(task); const sourceDisplayName = inferSourceDisplayName(task); const sourceExternalId = inferSourceExternalId(task); + const automationDisplayName = resolveTaskAutomationDisplayName(task); const githubSourceIdentity = sourceKind === 'github' ? getTaskGithubIdentity(task) : { githubLogin: null, githubUserId: null }; + // Webhook/background automation task types are product-owned, even when a + // GitHub/GitLab actor presence is available on the collate path. + if ( + task.type === CloudTaskType.GithubPrReview || + task.type === CloudTaskType.GithubPrReviewSync || + task.type === CloudTaskType.GithubPrReviewFollowUp || + task.type === CloudTaskType.GithubPrConflictResolve + ) { + return { + attributionKind: 'automatic', + attributedUserId: null, + attributionSourceKind: 'automation', + attributionSourceDisplayName: + automationDisplayName ?? sourceDisplayName ?? 'Automations', + attributionSourceExternalId: null, + attributedGithubLogin: null, + attributedGithubUserId: null, + }; + } + let attributedUserId: string | null = null; if ( @@ -607,8 +637,11 @@ export async function buildTaskAttributionSnapshot( return { attributionKind: 'automatic', attributedUserId: null, - attributionSourceKind: sourceKind, - attributionSourceDisplayName: sourceDisplayName, + attributionSourceKind: + isKnownAutomationTaskType(task.type) || automationDisplayName + ? 'automation' + : sourceKind, + attributionSourceDisplayName: automationDisplayName ?? sourceDisplayName, attributionSourceExternalId: sourceExternalId, attributedGithubLogin: null, attributedGithubUserId: null, diff --git a/packages/types/src/cloud-jobs.ts b/packages/types/src/cloud-jobs.ts index 7bf56192..8a1ea613 100644 --- a/packages/types/src/cloud-jobs.ts +++ b/packages/types/src/cloud-jobs.ts @@ -730,6 +730,8 @@ const sharedTaskSchema = z.object({ .object({ kind: z.literal('automatic'), sourceKind: z.enum(TASK_ATTRIBUTION_SOURCE_KINDS).optional(), + /** User-facing automation name for attribution/analytics. */ + displayName: z.string().min(1).optional(), }) .optional(), /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d28f029d..75e0a310 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -6,6 +6,7 @@ export * from './background-agents'; export * from './background-automation-registry'; export * from './cloud-agents'; export * from './cloud-jobs'; +export * from './task-automation-display'; export * from './chatgpt-subscription'; export * from './communication'; export * from './identity-display-name'; diff --git a/packages/types/src/task-automation-display.ts b/packages/types/src/task-automation-display.ts new file mode 100644 index 00000000..d9cd038b --- /dev/null +++ b/packages/types/src/task-automation-display.ts @@ -0,0 +1,79 @@ +import { CloudAgentType } from './cloud-agents'; +import { CloudTaskType, type TaskSuggestionSource } from './cloud-jobs'; +import { + getScheduledSuggestionBackgroundAutomationDescriptor, + getTriggerableBackgroundAutomationDescriptorByKey, +} from './background-automation-registry'; +import type { BackgroundAutomationKey } from './background-agents'; + +const PR_REVIEWER_AUTOMATION_LABEL = CloudAgentType.PrReviewer; +const PR_CONFLICT_AUTOMATION_LABEL = 'Resolve PR Conflicts'; + +type TaskAutomationDisplayInput = { + type: CloudTaskType; + payload?: { + suggestionSource?: TaskSuggestionSource; + automationKey?: BackgroundAutomationKey | string; + [key: string]: unknown; + } | null; +}; + +/** + * User-facing automation name for task attribution/analytics when a task was + * kicked by a known automation rather than a linked product user. + */ +export function resolveTaskAutomationDisplayName( + task: TaskAutomationDisplayInput, +): string | null { + switch (task.type) { + case CloudTaskType.GithubPrReview: + case CloudTaskType.GithubPrReviewSync: + case CloudTaskType.GithubPrReviewFollowUp: + return PR_REVIEWER_AUTOMATION_LABEL; + case CloudTaskType.GithubPrConflictResolve: + return PR_CONFLICT_AUTOMATION_LABEL; + default: + break; + } + + const payload = task.payload; + if (!payload || typeof payload !== 'object') { + return null; + } + + const automationKey = + typeof payload.automationKey === 'string' + ? payload.automationKey.trim() + : null; + if (automationKey) { + const byKey = getTriggerableBackgroundAutomationDescriptorByKey( + automationKey as BackgroundAutomationKey, + ); + if (byKey?.label) { + return byKey.label; + } + } + + if (payload.suggestionSource) { + const bySource = getScheduledSuggestionBackgroundAutomationDescriptor( + payload.suggestionSource, + ); + if (bySource?.label) { + return bySource.label; + } + } + + return null; +} + +export function isKnownAutomationTaskType(type: CloudTaskType): boolean { + return ( + type === CloudTaskType.GithubPrReview || + type === CloudTaskType.GithubPrReviewSync || + type === CloudTaskType.GithubPrReviewFollowUp || + type === CloudTaskType.GithubPrConflictResolve || + type === CloudTaskType.SuggestedTasks || + type === CloudTaskType.McpRecommendations || + type === CloudTaskType.LegacyOnboardingSuggestions + ); +}