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
35 changes: 35 additions & 0 deletions backend/src/services/slack/reviewDecisionCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@crowd/slack')>()),
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'
Expand Down Expand Up @@ -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 () => {
Expand Down
29 changes: 28 additions & 1 deletion backend/src/services/slack/reviewDecisionCommand.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<void> {
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.',
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -28,7 +43,7 @@ import {
buildOnboardedDiscussionAlert,
buildOnboardedDiscussionTitle,
groupGithubDiscussionRequestsBySource,
isReviewAlertRequest,
isGithubDiscussionRequest,
} from './discussionRequestAlert'
import { buildInsightsProjectSkipReason } from './insightsProjectSkip'
import { runProposedActionSerialized } from './proposedAction'
Expand Down Expand Up @@ -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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry drops skipped thread reply

Medium Severity

resolveUnclaimedOutcome returns already-skipped whenever action is already skip, including when an earlier attempt of this same activity wrote that skip and then failed before Temporal stored the result. onboardProjects posts the thread reply only for skipped, and onboardAndUpdateProject is allowed a second attempt, so that retry never sends the outcome.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c755a2d. Configure here.

Comment thread
ulemons marked this conversation as resolved.
}
log.info(
{ id: project.id, repoUrl: project.repoUrl, action: current?.action },
Expand Down Expand Up @@ -150,7 +165,31 @@ export async function onboardAndUpdateProject(
return 'onboarded'
}

async function replyInBotThread(
project: Pick<IDbProjectCatalog, 'id' | 'repoUrl' | 'sourceUrl'>,
text: string,
outcome: string,
): Promise<void> {
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<void> {
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,
Expand Down Expand Up @@ -188,11 +227,35 @@ export async function markProjectOnboardingFailed(
return true
}

export async function notifySkippedBotRequest(project: IDbProjectCatalog): Promise<void> {
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')
}
Comment thread
cursor[bot] marked this conversation as resolved.

export async function notifyErroredHumanRequest(
project: IDbProjectCatalog,
reason: string,
): Promise<void> {
if (!isReviewAlertRequest(project)) {
if (isSlackBotProvenance(project.provenance)) {
await replyInBotThread(project, buildBotErroredReply(project, reason), 'error')
return
}

if (!isGithubDiscussionRequest(project)) {
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
buildOnboardedDiscussionTitle,
groupGithubDiscussionRequestsBySource,
isGithubDiscussionRequest,
isReviewAlertRequest,
} from './discussionRequestAlert'

describe('isGithubDiscussionRequest', () => {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IDbProjectCatalog, 'provenance'>): boolean {
return project.provenance === 'github-discussion'
}

export function isReviewAlertRequest(project: Pick<IDbProjectCatalog, 'provenance'>): boolean {
return isReviewAlertProvenance(project.provenance)
}

function formatRequestedIn(
project: Pick<IDbProjectCatalog, 'sourceUrl'> & Partial<Pick<IDbProjectCatalog, 'provenance'>>,
): string {
Expand Down Expand Up @@ -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 [
Expand Down
1 change: 1 addition & 0 deletions services/apps/automatic_onboarding_worker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export interface IOnboardProjectsInput {
export type OnboardAndUpdateProjectOutcome =
| 'onboarded'
| 'skipped'
| 'already-skipped'
| 'already-onboarded'
| 'catalog-changed'
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ export async function onboardProjects(input: IOnboardProjectsInput = {}): Promis
if (outcome === 'catalog-changed') {
racedOut++
}
if (outcome === 'skipped') {
try {
await notifyActivities.notifySkippedBotRequest(project)
Comment on lines +83 to +85
} 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)) {
Expand Down
Loading
Loading