diff --git a/backend/src/services/slack/reviewDecisionCommand.test.ts b/backend/src/services/slack/reviewDecisionCommand.test.ts index 27b96020db..5b449e29dd 100644 --- a/backend/src/services/slack/reviewDecisionCommand.test.ts +++ b/backend/src/services/slack/reviewDecisionCommand.test.ts @@ -10,8 +10,13 @@ vi.mock('./onboardProjectCommand', () => ({ textMessage: vi.fn((text: string) => ({ text })), })) vi.mock('./slackBackground', () => ({ getBgQx: vi.fn(async () => ({})) })) +vi.mock('@crowd/slack', async (importOriginal) => ({ + ...(await importOriginal()), + replyInSlackThread: vi.fn(async () => ({ ok: true })), +})) import { resolveProjectCatalogReview } from '@crowd/data-access-layer' +import { replyInSlackThread } from '@crowd/slack' import { postToResponseUrl } from './onboardProjectCommand' import { postReviewDecisionFailure, runReviewDecisionCommand } from './reviewDecisionCommand' @@ -82,6 +87,36 @@ describe('runReviewDecisionCommand', () => { }) expect(startWorkflow).not.toHaveBeenCalled() expect(replacedMessage()).toMatchObject({ text: 'Rejected — kops', replace_original: true }) + expect(replyInSlackThread).not.toHaveBeenCalled() + }) + + it('tells the requester in the Slack thread when a bot request is rejected', async () => { + const sourceUrl = 'https://acme.slack.com/archives/C0123ABC/p1760000000123456' + vi.mocked(resolveProjectCatalogReview).mockResolvedValue({ + ...reviewedRow, + provenance: 'slack-bot', + sourceUrl, + skipReason: 'rejected in review by U123', + }) + + await run({ decision: 'reject' }) + + expect(replyInSlackThread).toHaveBeenCalledWith( + sourceUrl, + expect.stringContaining('rejected in review by U123'), + ) + }) + + it('does not reply in the thread when a bot request is approved', async () => { + vi.mocked(resolveProjectCatalogReview).mockResolvedValue({ + ...reviewedRow, + provenance: 'slack-bot', + sourceUrl: 'https://acme.slack.com/archives/C0123ABC/p1760000000123456', + }) + + await run() + + expect(replyInSlackThread).not.toHaveBeenCalled() }) it('starts a new workflow for a later review cycle of the same row', async () => { diff --git a/backend/src/services/slack/reviewDecisionCommand.ts b/backend/src/services/slack/reviewDecisionCommand.ts index 96d0ceeadd..64a59b5015 100644 --- a/backend/src/services/slack/reviewDecisionCommand.ts +++ b/backend/src/services/slack/reviewDecisionCommand.ts @@ -1,7 +1,12 @@ import { z } from 'zod' import { resolveProjectCatalogReview } from '@crowd/data-access-layer' -import { ReviewDecision, buildReviewDecisionMessage } from '@crowd/slack' +import { + IDbProjectCatalog, + isSlackBotProvenance, +} from '@crowd/data-access-layer/src/project-catalog/types' +import { buildBotSkippedReply } from '@crowd/project-onboarding' +import { ReviewDecision, buildReviewDecisionMessage, replyInSlackThread } from '@crowd/slack' import { WorkflowIdReusePolicy } from '@crowd/temporal' import { IServiceOptions } from '../IServiceOptions' @@ -119,4 +124,26 @@ export async function runReviewDecisionCommand({ buildReviewDecisionMessage(resolved, decision, actorId, { onboardingStarted }), log, ) + + if (decision === 'reject') { + await replyRejectionInBotThread(resolved, log) + } +} + +async function replyRejectionInBotThread( + row: IDbProjectCatalog, + log: IServiceOptions['log'], +): Promise { + if (!isSlackBotProvenance(row.provenance)) { + return + } + + const reason = row.skipReason ?? 'rejected in review' + const result = await replyInSlackThread(row.sourceUrl, buildBotSkippedReply(row, reason)) + if (!result.ok) { + log.warn( + { catalogId: row.id, sourceUrl: row.sourceUrl, error: result.error }, + 'Rejection reply in the Slack bot thread was not sent.', + ) + } } diff --git a/services/apps/automatic_onboarding_worker/src/activities/activities.ts b/services/apps/automatic_onboarding_worker/src/activities/activities.ts index 9d20aa6fbe..f5cf4ea416 100644 --- a/services/apps/automatic_onboarding_worker/src/activities/activities.ts +++ b/services/apps/automatic_onboarding_worker/src/activities/activities.ts @@ -14,12 +14,27 @@ import { findInsightsProjectBySlugIncludingDeleted, } from '@crowd/data-access-layer/src/collections' import { IPipelineRunFinish } from '@crowd/data-access-layer/src/project-catalog-pipeline-runs/types' -import { IDbProjectCatalog } from '@crowd/data-access-layer/src/project-catalog/types' +import { + IDbProjectCatalog, + isSlackBotProvenance, +} from '@crowd/data-access-layer/src/project-catalog/types' import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor' import { getServiceLogger } from '@crowd/logging' -import { connectRepoToSegment, deriveProjectSlug, onboardProject } from '@crowd/project-onboarding' +import { + buildBotErroredReply, + buildBotOnboardedReply, + buildBotSkippedReply, + connectRepoToSegment, + deriveProjectSlug, + onboardProject, +} from '@crowd/project-onboarding' import { createCdpSegmentLookup } from '@crowd/project-onboarding/src/cdpSegmentLookup' -import { SlackChannel, SlackPersona, sendSlackNotificationAsync } from '@crowd/slack' +import { + SlackChannel, + SlackPersona, + replyInSlackThread, + sendSlackNotificationAsync, +} from '@crowd/slack' import { svc } from '../main' import { OnboardAndUpdateProjectOutcome } from '../types' @@ -28,7 +43,7 @@ import { buildOnboardedDiscussionAlert, buildOnboardedDiscussionTitle, groupGithubDiscussionRequestsBySource, - isReviewAlertRequest, + isGithubDiscussionRequest, } from './discussionRequestAlert' import { buildInsightsProjectSkipReason } from './insightsProjectSkip' import { runProposedActionSerialized } from './proposedAction' @@ -70,7 +85,7 @@ async function resolveUnclaimedOutcome( } if (current?.action === 'skip') { log.info({ id: project.id, repoUrl: project.repoUrl }, 'Project already skipped.') - return 'skipped' + return 'already-skipped' } log.info( { id: project.id, repoUrl: project.repoUrl, action: current?.action }, @@ -150,7 +165,31 @@ export async function onboardAndUpdateProject( return 'onboarded' } +async function replyInBotThread( + project: Pick, + text: string, + outcome: string, +): Promise { + const result = await replyInSlackThread(project.sourceUrl, text) + + if (!result.ok) { + log.warn( + { + id: project.id, + repoUrl: project.repoUrl, + sourceUrl: project.sourceUrl, + error: result.error, + }, + `Slack bot thread reply (${outcome}) was not sent.`, + ) + } +} + export async function notifyOnboardedHumanRequests(projects: IDbProjectCatalog[]): Promise { + for (const project of projects.filter((row) => isSlackBotProvenance(row.provenance))) { + await replyInBotThread(project, buildBotOnboardedReply(project), 'onboarded') + } + for (const group of groupGithubDiscussionRequestsBySource(projects)) { const sent = await sendSlackNotificationAsync( SlackChannel.CDP_PROJECT_CATALOG_SKIP_ALERTS, @@ -188,11 +227,35 @@ export async function markProjectOnboardingFailed( return true } +export async function notifySkippedBotRequest(project: IDbProjectCatalog): Promise { + if (!isSlackBotProvenance(project.provenance)) { + return + } + + const qx = pgpQx(svc.postgres.writer.connection()) + const current = await findProjectCatalogById(qx, project.id) + if (current?.action !== 'skip') { + log.info( + { id: project.id, repoUrl: project.repoUrl, action: current?.action }, + 'Row is no longer skipped, not replying in the Slack thread.', + ) + return + } + + const reason = current.skipReason ?? '(no reason recorded)' + await replyInBotThread(project, buildBotSkippedReply(project, reason), 'skipped') +} + export async function notifyErroredHumanRequest( project: IDbProjectCatalog, reason: string, ): Promise { - if (!isReviewAlertRequest(project)) { + if (isSlackBotProvenance(project.provenance)) { + await replyInBotThread(project, buildBotErroredReply(project, reason), 'error') + return + } + + if (!isGithubDiscussionRequest(project)) { return } diff --git a/services/apps/automatic_onboarding_worker/src/activities/discussionRequestAlert.test.ts b/services/apps/automatic_onboarding_worker/src/activities/discussionRequestAlert.test.ts index e24d959594..e2afb96c1a 100644 --- a/services/apps/automatic_onboarding_worker/src/activities/discussionRequestAlert.test.ts +++ b/services/apps/automatic_onboarding_worker/src/activities/discussionRequestAlert.test.ts @@ -7,7 +7,6 @@ import { buildOnboardedDiscussionTitle, groupGithubDiscussionRequestsBySource, isGithubDiscussionRequest, - isReviewAlertRequest, } from './discussionRequestAlert' describe('isGithubDiscussionRequest', () => { @@ -233,19 +232,6 @@ describe('buildErroredDiscussionAlert', () => { }) }) -describe('isReviewAlertRequest', () => { - it('is true for github-discussion and slack-bot provenance', () => { - expect(isReviewAlertRequest({ provenance: 'github-discussion' })).toBe(true) - expect(isReviewAlertRequest({ provenance: 'slack-bot' })).toBe(true) - }) - - it('is false for slack-tag, bulk and null provenance', () => { - expect(isReviewAlertRequest({ provenance: 'slack-tag' })).toBe(false) - expect(isReviewAlertRequest({ provenance: 'lf-criticality-score' })).toBe(false) - expect(isReviewAlertRequest({ provenance: null })).toBe(false) - }) -}) - describe('buildErroredDiscussionAlert for slack-bot requests', () => { it('links the Slack message', () => { const [header] = buildErroredDiscussionAlert( diff --git a/services/apps/automatic_onboarding_worker/src/activities/discussionRequestAlert.ts b/services/apps/automatic_onboarding_worker/src/activities/discussionRequestAlert.ts index cb1fe098bb..de15397aef 100644 --- a/services/apps/automatic_onboarding_worker/src/activities/discussionRequestAlert.ts +++ b/services/apps/automatic_onboarding_worker/src/activities/discussionRequestAlert.ts @@ -1,23 +1,17 @@ import { IDbProjectCatalog, - isReviewAlertProvenance, isSlackBotProvenance, isSlackPermalink, } from '@crowd/data-access-layer/src/project-catalog/types' -import { deriveProjectSlug } from '@crowd/project-onboarding' +import { buildInsightsProjectUrl } from '@crowd/project-onboarding' import { SlackMessageSection } from '@crowd/slack' -const INSIGHTS_PROJECT_URL_BASE = 'https://insights.linuxfoundation.org/project' const MAX_REASON_LENGTH = 500 export function isGithubDiscussionRequest(project: Pick): boolean { return project.provenance === 'github-discussion' } -export function isReviewAlertRequest(project: Pick): boolean { - return isReviewAlertProvenance(project.provenance) -} - function formatRequestedIn( project: Pick & Partial>, ): string { @@ -76,13 +70,11 @@ export function buildOnboardedDiscussionTitle(projects: OnboardedDiscussionProje export function buildOnboardedDiscussionReply(projects: OnboardedDiscussionProject[]): string { if (projects.length === 1) { const [project] = projects - const slug = deriveProjectSlug(project.projectSlug) - return `Thanks for the suggestion! ${project.repoName} is now onboarded to LFX Insights — data collection has started and the first metrics can take a few hours to show up. You can follow it here: ${INSIGHTS_PROJECT_URL_BASE}/${slug}` + return `Thanks for the suggestion! ${project.repoName} is now onboarded to LFX Insights — data collection has started and the first metrics can take a few hours to show up. You can follow it here: ${buildInsightsProjectUrl(project.projectSlug)}` } const repoLines = projects.map( - (project) => - `- ${project.repoName}: ${INSIGHTS_PROJECT_URL_BASE}/${deriveProjectSlug(project.projectSlug)}`, + (project) => `- ${project.repoName}: ${buildInsightsProjectUrl(project.projectSlug)}`, ) return [ diff --git a/services/apps/automatic_onboarding_worker/src/types.ts b/services/apps/automatic_onboarding_worker/src/types.ts index 65159e6c60..3e7dafb503 100644 --- a/services/apps/automatic_onboarding_worker/src/types.ts +++ b/services/apps/automatic_onboarding_worker/src/types.ts @@ -6,5 +6,6 @@ export interface IOnboardProjectsInput { export type OnboardAndUpdateProjectOutcome = | 'onboarded' | 'skipped' + | 'already-skipped' | 'already-onboarded' | 'catalog-changed' diff --git a/services/apps/automatic_onboarding_worker/src/workflows/onboardProjects.ts b/services/apps/automatic_onboarding_worker/src/workflows/onboardProjects.ts index bd11d62cd8..fdc3b0c914 100644 --- a/services/apps/automatic_onboarding_worker/src/workflows/onboardProjects.ts +++ b/services/apps/automatic_onboarding_worker/src/workflows/onboardProjects.ts @@ -80,6 +80,16 @@ export async function onboardProjects(input: IOnboardProjectsInput = {}): Promis if (outcome === 'catalog-changed') { racedOut++ } + if (outcome === 'skipped') { + try { + await notifyActivities.notifySkippedBotRequest(project) + } catch (notifyErr) { + // A failed reply must never turn a recorded skip into a batch failure. + log.error( + `Failed to send the skipped reply for project id=${project.id}: ${String(notifyErr)}`, + ) + } + } } } catch (err) { if (isCancellation(err)) { diff --git a/services/apps/projects_evaluation_worker/src/activities/activities.ts b/services/apps/projects_evaluation_worker/src/activities/activities.ts index 43f1ddf82e..7387cdb955 100644 --- a/services/apps/projects_evaluation_worker/src/activities/activities.ts +++ b/services/apps/projects_evaluation_worker/src/activities/activities.ts @@ -19,11 +19,17 @@ import { import { IPipelineRunFinish } from '@crowd/data-access-layer/src/project-catalog-pipeline-runs/types' import { IDbProjectCatalog, - isReviewAlertProvenance, + isGithubDiscussionProvenance, + isSlackBotProvenance, } from '@crowd/data-access-layer/src/project-catalog/types' import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor' import { getServiceLogger } from '@crowd/logging' -import { classifyCatalogRow } from '@crowd/project-onboarding' +import { + buildBotAutoApplyDisabledReply, + buildBotReviewReply, + buildBotSkippedReply, + classifyCatalogRow, +} from '@crowd/project-onboarding' import { loadBatchRepoLookups, withCatalogRowClassifierDeps, @@ -32,6 +38,7 @@ import { SlackChannel, SlackPersona, postReviewRequest, + replyInSlackThread, sendSlackNotificationAsync, } from '@crowd/slack' import { WorkflowIdReusePolicy } from '@crowd/temporal' @@ -43,6 +50,7 @@ import { IClassifyAndRouteResult, IEvaluationActivityResult, IImmediateOnboardingPlan, + IImmediateOnboardingRef, IImmediateOnboardingResult, IPriorityConfig, } from '../types' @@ -219,11 +227,36 @@ export async function evaluateAndUpdateProject( } } +async function replyInBotThread( + project: Pick, + text: string, + outcome: string, +): Promise { + const result = await replyInSlackThread(project.sourceUrl, text) + + if (!result.ok) { + log.warn( + { + id: project.id, + repoUrl: project.repoUrl, + sourceUrl: project.sourceUrl, + error: result.error, + }, + `Slack bot thread reply (${outcome}) was not sent.`, + ) + } +} + export async function notifySkippedHumanRequest( project: IDbProjectCatalog, reason: string, ): Promise { - if (!isReviewAlertProvenance(project.provenance)) { + if (isSlackBotProvenance(project.provenance)) { + await replyInBotThread(project, buildBotSkippedReply(project, reason), 'skipped') + return + } + + if (!isGithubDiscussionProvenance(project.provenance)) { return } @@ -260,14 +293,14 @@ export async function fetchReviewRequests(catalogIds?: string[]): Promise { const qx = pgpQx(svc.postgres.writer.connection()) - await qx.tx(async (tx) => { + const posted = await qx.tx(async (tx): Promise => { const locked = await lockProjectCatalogReviewRequest(tx, project.id) if (!locked) { log.info( { id: project.id, repoUrl: project.repoUrl }, 'Review request already posted by another run or the row left review.', ) - return + return false } const result = await postReviewRequest(project) @@ -276,12 +309,21 @@ export async function notifyReviewRequest(project: IDbProjectCatalog): Promise { + await replyInBotThread(row, buildBotAutoApplyDisabledReply(row), 'auto-apply disabled') } async function startImmediateOnboarding(catalogIds: string[], workflowId: string): Promise { @@ -311,6 +353,16 @@ export async function planImmediateOnboarding( return buildImmediateOnboardingPlan(pendingOnboarding, ONBOARDING_AUTO_APPLY) } +// Only this activity deletes pending bot rows, so a missing row means a retried attempt +// already removed it and its thread reply is still owed. +async function wasRemovedByEarlierAttempt( + qx: ReturnType, + id: string, +): Promise { + const current = await findProjectCatalogById(qx, id) + return current === null +} + export async function applyImmediateOnboarding( plan: IImmediateOnboardingPlan, workflowId: string, @@ -321,9 +373,13 @@ export async function applyImmediateOnboarding( for (const row of plan.toRemove) { const deleted = await deleteProjectCatalogPendingSlackBotOnboarding(qx, row.id) if (deleted === 0) { + if (await wasRemovedByEarlierAttempt(qx, row.id)) { + removed.push(row.repoUrl) + continue + } log.info( { id: row.id, repoUrl: row.repoUrl }, - 'Row already removed or changed since the plan was made, leaving it alone.', + 'Row changed since the plan was made, leaving it alone.', ) continue } diff --git a/services/apps/projects_evaluation_worker/src/activities/immediateOnboarding.test.ts b/services/apps/projects_evaluation_worker/src/activities/immediateOnboarding.test.ts index 63c4b2e349..55e5948fae 100644 --- a/services/apps/projects_evaluation_worker/src/activities/immediateOnboarding.test.ts +++ b/services/apps/projects_evaluation_worker/src/activities/immediateOnboarding.test.ts @@ -45,7 +45,7 @@ describe('buildImmediateOnboardingPlan', () => { provenance: 'github-discussion', }) - const botRef = { id: 'bot', repoUrl: 'https://github.com/acme/widget' } + const botRef = { id: 'bot', repoUrl: 'https://github.com/acme/widget', sourceUrl: null } it('onboards slack bot rows right away when auto apply is on', () => { expect(buildImmediateOnboardingPlan([bot, discovery], true)).toEqual({ diff --git a/services/apps/projects_evaluation_worker/src/activities/immediateOnboarding.ts b/services/apps/projects_evaluation_worker/src/activities/immediateOnboarding.ts index cb24634e5b..fbb900f686 100644 --- a/services/apps/projects_evaluation_worker/src/activities/immediateOnboarding.ts +++ b/services/apps/projects_evaluation_worker/src/activities/immediateOnboarding.ts @@ -1,13 +1,12 @@ import { IDbProjectCatalog, - IDbProjectCatalogRef, isSlackBotProvenance, } from '@crowd/data-access-layer/src/project-catalog/types' -import { IImmediateOnboardingPlan } from '../types' +import { IImmediateOnboardingPlan, IImmediateOnboardingRef } from '../types' -function toRef({ id, repoUrl }: IDbProjectCatalog): IDbProjectCatalogRef { - return { id, repoUrl } +function toRef({ id, repoUrl, sourceUrl }: IDbProjectCatalog): IImmediateOnboardingRef { + return { id, repoUrl, sourceUrl } } export function buildImmediateOnboardingPlan( diff --git a/services/apps/projects_evaluation_worker/src/types.ts b/services/apps/projects_evaluation_worker/src/types.ts index 8b9eba0c3f..431e3d13b9 100644 --- a/services/apps/projects_evaluation_worker/src/types.ts +++ b/services/apps/projects_evaluation_worker/src/types.ts @@ -1,6 +1,5 @@ import { IDbProjectCatalog, - IDbProjectCatalogRef, ProjectCatalogRoutedAction, } from '@crowd/data-access-layer/src/project-catalog/types' @@ -22,9 +21,11 @@ export interface IEvaluateProjectsInput { catalogIds?: string[] } +export type IImmediateOnboardingRef = Pick + export interface IImmediateOnboardingPlan { - toOnboard: IDbProjectCatalogRef[] - toRemove: IDbProjectCatalogRef[] + toOnboard: IImmediateOnboardingRef[] + toRemove: IImmediateOnboardingRef[] } export interface IImmediateOnboardingResult { diff --git a/services/apps/projects_evaluation_worker/src/workflows/evaluateProjects.ts b/services/apps/projects_evaluation_worker/src/workflows/evaluateProjects.ts index bbbd4c5fa7..29e112ca69 100644 --- a/services/apps/projects_evaluation_worker/src/workflows/evaluateProjects.ts +++ b/services/apps/projects_evaluation_worker/src/workflows/evaluateProjects.ts @@ -223,6 +223,18 @@ export async function evaluateProjects(input: IEvaluateProjectsInput = {}): Prom if (catalogIds) { const plan = await dispatchActivities.planImmediateOnboarding(catalogIds) immediateOnboarding = await dispatchActivities.applyImmediateOnboarding(plan, workflowId) + + const removed = new Set(immediateOnboarding.removed) + for (const row of plan.toRemove.filter((candidate) => removed.has(candidate.repoUrl))) { + try { + await notifyActivities.notifyAutoApplyDisabled(row) + } catch (notifyErr) { + // A failed reply must never turn a recorded removal into a batch failure. + log.error( + `Failed to send the auto-apply reply for project id=${row.id}: ${String(notifyErr)}`, + ) + } + } } await pipelineRunActivities.finishEvaluationPipelineRun(pipelineRunId, { diff --git a/services/libs/project-onboarding/src/botThreadReply.test.ts b/services/libs/project-onboarding/src/botThreadReply.test.ts new file mode 100644 index 0000000000..f85a5cc340 --- /dev/null +++ b/services/libs/project-onboarding/src/botThreadReply.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { + buildBotAutoApplyDisabledReply, + buildBotErroredReply, + buildBotOnboardedReply, + buildBotReviewReply, + buildBotSkippedReply, + buildInsightsProjectUrl, +} from './botThreadReply' + +const project = { + repoUrl: 'https://github.com/acme/widget', + projectSlug: 'acme-widget', + proposedAction: null, +} + +describe('botThreadReply', () => { + it('links the insights project when a segment was created', () => { + expect(buildBotOnboardedReply(project)).toContain(buildInsightsProjectUrl('acme-widget')) + expect(buildBotOnboardedReply({ ...project, proposedAction: 'create_segment' })).toContain( + 'https://insights.linuxfoundation.org/project/', + ) + }) + + it('reports a connection to the existing project for integration actions', () => { + for (const proposedAction of ['create_integration', 'update_integration'] as const) { + const reply = buildBotOnboardedReply({ ...project, proposedAction }) + expect(reply).toContain('existing LFX Insights project') + expect(reply).not.toContain('insights.linuxfoundation.org') + } + }) + + it('truncates long reasons and escapes mrkdwn control characters', () => { + const reply = buildBotSkippedReply( + { repoUrl: 'https://github.com/acme/' }, + `a & b ${'x'.repeat(600)}`, + ) + + expect(reply).toContain('`https://github.com/acme/<widget>`') + expect(reply).toContain('a & b') + expect(reply).toContain('…') + expect(reply.length).toBeLessThan(700) + }) + + it('builds the review, auto apply and error replies around the repository', () => { + expect(buildBotReviewReply(project)).toContain('review channel') + expect(buildBotAutoApplyDisabledReply(project)).toContain('disabled') + expect(buildBotErroredReply(project, 'boom')).toContain('boom') + }) +}) diff --git a/services/libs/project-onboarding/src/botThreadReply.ts b/services/libs/project-onboarding/src/botThreadReply.ts new file mode 100644 index 0000000000..39f5ac30f2 --- /dev/null +++ b/services/libs/project-onboarding/src/botThreadReply.ts @@ -0,0 +1,51 @@ +import { IDbProjectCatalog } from '@crowd/data-access-layer/src/project-catalog/types' +import { escapeSlackText } from '@crowd/slack' + +import { deriveProjectSlug } from './onboarder' + +export const INSIGHTS_PROJECT_URL_BASE = 'https://insights.linuxfoundation.org/project' +const MAX_REASON_LENGTH = 500 + +type BotReplyProject = Pick +type BotOnboardedProject = Pick + +export function buildInsightsProjectUrl(projectSlug: string): string { + return `${INSIGHTS_PROJECT_URL_BASE}/${deriveProjectSlug(projectSlug)}` +} + +function repo(project: BotReplyProject): string { + return `\`${escapeSlackText(project.repoUrl)}\`` +} + +function formatReason(reason: string): string { + const truncated = + reason.length > MAX_REASON_LENGTH ? `${reason.slice(0, MAX_REASON_LENGTH)}…` : reason + return ['```', escapeSlackText(truncated), '```'].join('\n') +} + +export function buildBotSkippedReply(project: BotReplyProject, reason: string): string { + return [`:no_entry_sign: ${repo(project)} was not onboarded.`, formatReason(reason)].join('\n') +} + +export function buildBotReviewReply(project: BotReplyProject): string { + return `:eyes: ${repo(project)} needs a human review. The request was posted to the review channel, the outcome will be posted here.` +} + +export function buildBotAutoApplyDisabledReply(project: BotReplyProject): string { + return `:warning: Automatic onboarding is disabled, ${repo(project)} was not onboarded.` +} + +export function buildBotOnboardedReply(project: BotOnboardedProject): string { + if ( + project.proposedAction === 'create_integration' || + project.proposedAction === 'update_integration' + ) { + return `:white_check_mark: ${repo(project)} is now connected to its existing LFX Insights project. Data collection has started and the first metrics can take a few hours to show up.` + } + + return `:white_check_mark: ${repo(project)} is now onboarded to LFX Insights. Data collection has started and the first metrics can take a few hours to show up: ${buildInsightsProjectUrl(project.projectSlug)}` +} + +export function buildBotErroredReply(project: BotReplyProject, reason: string): string { + return [`:x: Onboarding ${repo(project)} failed.`, formatReason(reason)].join('\n') +} diff --git a/services/libs/project-onboarding/src/index.ts b/services/libs/project-onboarding/src/index.ts index c42a4a05b4..04c4739c71 100644 --- a/services/libs/project-onboarding/src/index.ts +++ b/services/libs/project-onboarding/src/index.ts @@ -1,3 +1,4 @@ +export * from './botThreadReply' export * from './classificationTrace' export * from './classifyCatalogRow' export * from './classifyRequest' diff --git a/services/libs/slack/src/index.ts b/services/libs/slack/src/index.ts index 611204b30c..f8c401960a 100644 --- a/services/libs/slack/src/index.ts +++ b/services/libs/slack/src/index.ts @@ -6,3 +6,4 @@ export * from './botConfig' export * from './chatClient' export * from './mrkdwn' export * from './reviewRequest' +export * from './threadReply' diff --git a/services/libs/slack/src/threadReply.test.ts b/services/libs/slack/src/threadReply.test.ts new file mode 100644 index 0000000000..b0b6c4e4ab --- /dev/null +++ b/services/libs/slack/src/threadReply.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { postSlackMessage } = vi.hoisted(() => ({ postSlackMessage: vi.fn() })) +vi.mock('./chatClient', () => ({ postSlackMessage })) + +import { parseSlackPermalink, replyInSlackThread } from './threadReply' + +describe('parseSlackPermalink', () => { + it('parses a top level message permalink', () => { + expect( + parseSlackPermalink('https://acme.slack.com/archives/C0123ABC/p1760000000123456'), + ).toEqual({ channel: 'C0123ABC', ts: '1760000000.123456', threadTs: '1760000000.123456' }) + }) + + it('uses the parent thread when the permalink points at a reply', () => { + expect( + parseSlackPermalink( + 'https://acme.slack.com/archives/C0123ABC/p1760000000123456?thread_ts=1759999999.000100&cid=C0123ABC', + ), + ).toEqual({ channel: 'C0123ABC', ts: '1760000000.123456', threadTs: '1759999999.000100' }) + }) + + it('returns null for anything that is not a Slack message permalink', () => { + expect(parseSlackPermalink('https://github.com/acme/widget/discussions/1')).toBeNull() + expect(parseSlackPermalink('https://acme.slack.com/archives/C0123ABC')).toBeNull() + expect(parseSlackPermalink('https://acme.slack.com/archives/C0123ABC/p123')).toBeNull() + }) +}) + +describe('replyInSlackThread', () => { + beforeEach(() => { + postSlackMessage.mockReset() + }) + + it('posts the text in the thread of the linked message', async () => { + postSlackMessage.mockResolvedValue({ ok: true, ts: '1760000001.000000' }) + + const result = await replyInSlackThread( + 'https://acme.slack.com/archives/C0123ABC/p1760000000123456?thread_ts=1759999999.000100&cid=C0123ABC', + 'done', + ) + + expect(result).toEqual({ ok: true }) + expect(postSlackMessage).toHaveBeenCalledWith({ + channel: 'C0123ABC', + thread_ts: '1759999999.000100', + text: 'done', + }) + }) + + it('fails without posting when the source is not a Slack permalink', async () => { + const result = await replyInSlackThread('https://github.com/acme/widget/discussions/1', 'x') + + expect(result).toEqual({ ok: false, error: 'not a Slack message permalink' }) + expect(postSlackMessage).not.toHaveBeenCalled() + }) + + it('fails without posting when no source was recorded', async () => { + const result = await replyInSlackThread(null, 'x') + + expect(result.ok).toBe(false) + expect(postSlackMessage).not.toHaveBeenCalled() + }) + + it('surfaces the chat client error', async () => { + postSlackMessage.mockResolvedValue({ ok: false, error: 'channel_not_found' }) + + const result = await replyInSlackThread( + 'https://acme.slack.com/archives/C0123ABC/p1760000000123456', + 'x', + ) + + expect(result).toEqual({ ok: false, error: 'channel_not_found' }) + }) +}) diff --git a/services/libs/slack/src/threadReply.ts b/services/libs/slack/src/threadReply.ts new file mode 100644 index 0000000000..97d82597da --- /dev/null +++ b/services/libs/slack/src/threadReply.ts @@ -0,0 +1,46 @@ +import { postSlackMessage } from './chatClient' + +export interface ISlackPermalinkTarget { + channel: string + ts: string + threadTs: string +} + +export interface ISlackThreadReplyResult { + ok: boolean + error?: string +} + +const PERMALINK_PATTERN = + /^https:\/\/[^/]+\.slack\.com\/archives\/([A-Z0-9]+)\/p(\d{10})(\d{6})(?:\?(.*))?$/i + +export function parseSlackPermalink(url: string): ISlackPermalinkTarget | null { + const match = PERMALINK_PATTERN.exec(url) + if (!match) { + return null + } + + const [, channel, seconds, micros, query] = match + const ts = `${seconds}.${micros}` + const threadTs = new URLSearchParams(query ?? '').get('thread_ts') + + return { channel, ts, threadTs: threadTs || ts } +} + +export async function replyInSlackThread( + permalink: string | null, + text: string, +): Promise { + const target = permalink ? parseSlackPermalink(permalink) : null + if (!target) { + return { ok: false, error: 'not a Slack message permalink' } + } + + const result = await postSlackMessage({ + channel: target.channel, + thread_ts: target.threadTs, + text, + }) + + return result.ok ? { ok: true } : { ok: false, error: result.error } +}