Skip to content
Open
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 @@ -40,9 +40,10 @@ interface AzureDevOpsConfigProps {

const EVENT_CONFIG = [
{
id: 'post.created' as const,
label: 'Create work item from new feedback',
description: 'Automatically create an Azure DevOps work item when new feedback is submitted',
id: 'post.status_changed' as const,
label: 'Create work item when feedback is Planned',
description:
"Automatically create an Azure DevOps work item when a post's status changes to Planned",
},
]

Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/lib/server/domains/comments/comment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ export async function createComment(
let comment: Comment
let previousStatusName: string | null = null
let newStatusName: string | null = null
let previousStatusSlug: string | null = null
let newStatusSlug: string | null = null

if (shouldChangeStatus) {
// Fetch new status and current post status in parallel
Expand All @@ -174,6 +176,8 @@ export async function createComment(

previousStatusName = prevStatus?.name ?? 'Open'
newStatusName = newStatus.name
previousStatusSlug = prevStatus?.slug ?? 'open'
newStatusSlug = newStatus.slug

// Atomic transaction: insert comment + update post status + conditionally increment comment count
const result = await db.transaction(async (tx) => {
Expand Down Expand Up @@ -308,7 +312,7 @@ export async function createComment(
)

// Dispatch status change event if status was changed
if (shouldChangeStatus && previousStatusName && newStatusName) {
if (shouldChangeStatus && previousStatusName && newStatusName && newStatusSlug) {
await dispatchPostStatusChanged(
buildEventActor(author),
{
Expand All @@ -318,7 +322,9 @@ export async function createComment(
boardSlug: board.slug,
},
previousStatusName,
newStatusName
newStatusName,
previousStatusSlug ?? 'open',
newStatusSlug
)
}
}
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/lib/server/domains/posts/post.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,9 @@ export async function updatePost(
boardSlug: board.slug,
},
previousStatusName,
newStatus.name
newStatus.name,
previousStatus?.slug ?? 'open',
newStatus.slug
)

createActivity({
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/lib/server/domains/posts/post.status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export async function changeStatus(
boardSlug: board.slug,
},
previousStatusName,
newStatus.name
newStatus.name,
prevStatus?.slug ?? 'open',
newStatus.slug
)

createActivity({
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/lib/server/events/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,14 @@ export async function dispatchPostStatusChanged(
actor: EventActor,
post: PostStatusChangedInput,
previousStatus: string,
newStatus: string
newStatus: string,
previousStatusSlug: string,
newStatusSlug: string
): Promise<void> {
await dispatchEvent({
...eventEnvelope(actor),
type: 'post.status_changed',
data: { post, previousStatus, newStatus },
data: { post, previousStatus, newStatus, previousStatusSlug, newStatusSlug },
})
}

Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/lib/server/events/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,15 +317,18 @@ async function getIntegrationTargets(
const secrets = decryptSecrets<{ accessToken?: string }>(m.secrets)
accessToken = secrets.accessToken
} catch (error) {
log.error({ err: error, integration_type: m.integrationType }, 'failed to decrypt integration secrets')
log.error(
{ err: error, integration_type: m.integrationType },
'failed to decrypt integration secrets'
)
continue
}
}

targets.push({
type: m.integrationType,
target: { channelId },
config: { accessToken, rootUrl: context.portalBaseUrl },
config: { ...integrationConfig, accessToken, rootUrl: context.portalBaseUrl },
})
}

Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/server/events/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ export interface PostStatusChangedPayload {
post: EventPostRef
previousStatus: string
newStatus: string
/** Stable identifiers (names are editable) so consumers can match a status across renames. */
previousStatusSlug: string
newStatusSlug: string
}

export interface CommentCreatedPayload {
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/lib/server/integrations/azure-devops/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@ async function azureDevOpsApi(

if (!response.ok) {
const status = response.status
if (status === 401) throw Object.assign(new Error('Unauthorized'), { status })
if (status === 403) throw Object.assign(new Error('Forbidden'), { status })
if (status === 429) throw Object.assign(new Error('Rate limited'), { status })
if (status >= 500) throw Object.assign(new Error(`Server error ${status}`), { status })
throw Object.assign(new Error(`HTTP ${status}`), { status })
const detail = await response.text().catch(() => '')
if (status === 401) throw Object.assign(new Error('Unauthorized'), { status, detail })
if (status === 403) throw Object.assign(new Error('Forbidden'), { status, detail })
if (status === 429) throw Object.assign(new Error('Rate limited'), { status, detail })
if (status >= 500) throw Object.assign(new Error(`Server error ${status}`), { status, detail })
throw Object.assign(new Error(`HTTP ${status}: ${detail}`), { status, detail })
}

return response
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/lib/server/integrations/azure-devops/hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Azure DevOps hook handler.
* Creates work items when feedback events occur.
* Creates a work item when a post's status changes to "Planned".
*/

import type { HookHandler, HookResult } from '../../events/hook-types'
Expand All @@ -12,6 +12,9 @@ import { logger } from '@/lib/server/logger'

const log = logger.child({ component: 'azure-devops' })

/** Slug of the status that triggers work item creation (stable across renames of "Planned"). */
const TARGET_STATUS_SLUG = 'planned'

export interface AzureDevOpsTarget {
channelId: string // "projectName:workItemType"
}
Expand All @@ -28,7 +31,7 @@ export const azureDevOpsHook: HookHandler = {
const { channelId } = target as AzureDevOpsTarget
const { accessToken, organizationName, rootUrl } = config as AzureDevOpsConfig

if (event.type !== 'post.created') {
if (event.type !== 'post.status_changed' || event.data.newStatusSlug !== TARGET_STATUS_SLUG) {
return { success: true }
}

Expand Down
13 changes: 4 additions & 9 deletions apps/web/src/lib/server/integrations/azure-devops/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,21 @@
*/

import type { EventData } from '../../events/types'
import { stripHtml, truncate } from '../../events/hook-utils'
import { buildPostUrl, escapeHtml, getAuthorName } from '../message-utils'
import { buildPostUrl, escapeHtml } from '../message-utils'

export function buildAzureDevOpsWorkItemBody(
event: EventData,
rootUrl: string
): { title: string; description: string } {
if (event.type !== 'post.created') {
if (event.type !== 'post.status_changed') {
return { title: 'Feedback', description: '' }
}

const { post } = event.data
const { post, previousStatus, newStatus } = event.data
const postUrl = buildPostUrl(rootUrl, post.boardSlug, post.id)
const content = truncate(stripHtml(post.content), 2000)
const author = getAuthorName(post)

const description = [
`<p>${escapeHtml(content)}</p>`,
'<hr>',
`<p><strong>Submitted by:</strong> ${escapeHtml(author)}</p>`,
`<p><strong>Status:</strong> ${escapeHtml(previousStatus)} &rarr; ${escapeHtml(newStatus)}</p>`,
`<p><strong>Board:</strong> ${escapeHtml(post.boardSlug)}</p>`,
`<p><a href="${escapeHtml(postUrl)}">View in Quackback</a></p>`,
].join('\n')
Expand Down