diff --git a/src/api/index.ts b/src/api/index.ts index 1c20d4e..323737c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -36,6 +36,13 @@ export interface RetryMessageResult { succeeded?: string[] | null; failed?: string[] | null; outcomes?: RetryMessageOutcome[] | null; + /** Whether the repository was marked as published and left the queue. */ + posted?: boolean; + /** + * Present only when that marking failed: the post went out but the repository + * stayed in the queue, so the scheduled run will publish it again. + */ + posted_error?: string; } export interface CronJobHistoryResponse { @@ -203,6 +210,47 @@ export const retryMessagePost = async ( return response.json(); }; +/** + * Publishes a repository immediately to every enabled integration, instead of + * waiting for its turn in the publication queue. Content Maestro resolves the + * integrations itself: the dashboard's cached list must not decide what is sent. + * + * The request is synchronous and can take minutes - the Threads connector alone + * is configured with a 90 s timeout - so callers pass a signal and must treat an + * abort as "may still be running", not as "did not publish". + */ +export const publishMessageNow = async ( + url: string, + options?: { signal?: AbortSignal } +): Promise => { + const settings = getApiSettings(); + + if (!isApiConfigured()) { + throw new Error("API not configured. Check the Content Maestro settings."); + } + + const response = await fetch(`${settings.contentMaestro.apiBaseUrl}/api/message/publish`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${settings.contentMaestro.apiBearerToken}`, + }, + body: JSON.stringify({ url }), + signal: options?.signal, + }); + + if (!response.ok) { + if (response.status === 429) { + throw new Error("Rate limit exceeded. Please try again later."); + } + // Content Maestro answers errors as plain text. + const detail = (await response.text()).trim(); + throw new Error(detail || `Failed to publish the repository: ${response.status}`); + } + + return response.json(); +}; + export const updateCronStatus = async (name: string, is_active: boolean): Promise => { const settings = getApiSettings(); const isConfigured = isApiConfigured(); diff --git a/src/components/ui/base/dialog.tsx b/src/components/ui/base/dialog.tsx index 6297877..fab32c0 100644 --- a/src/components/ui/base/dialog.tsx +++ b/src/components/ui/base/dialog.tsx @@ -27,10 +27,19 @@ const DialogOverlay = React.forwardRef< )) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName +interface DialogContentProps + extends React.ComponentPropsWithoutRef { + /** + * Blocks the built-in close button while an operation must not be interrupted - + * closing mid-run would throw away the only place its result is shown. + */ + closeDisabled?: boolean; +} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + DialogContentProps +>(({ className, children, closeDisabled, ...props }, ref) => ( {children} - + Close diff --git a/src/components/ui/business/publish-repository-dialog.tsx b/src/components/ui/business/publish-repository-dialog.tsx new file mode 100644 index 0000000..c65dde0 --- /dev/null +++ b/src/components/ui/business/publish-repository-dialog.tsx @@ -0,0 +1,345 @@ +import { useEffect, useRef, useState } from 'react'; +import { AlertTriangle, Check, Clock, Loader2, Send, X } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../base/dialog'; +import { Button } from '../base/button'; +import { IntegrationIcon, getLanguageFlag } from './integration-icons'; +import { publishMessageNow, type RetryMessageResult } from '../../../api/index'; +import { maestroErrorMessage } from '../../../utils/api-error'; +import { + buildPublishRows, + publishedSomething, + summarizePublishResult, + type PublishIntegration, + type PublishRow, +} from '../../../utils/message-publish'; +import type { Repository } from '../../../types'; + +interface PublishRepositoryDialogProps { + /** The repository being published; null keeps the dialog closed. */ + repository: Repository | null; + /** True when this repository already sits at the head of the publication queue. */ + isNext: boolean; + isApiReady: boolean; + /** The enabled integrations a publish-now will reach. */ + integrations: PublishIntegration[]; + /** True while the integration list is still being fetched. */ + integrationsLoading?: boolean; + onClose: () => void; + /** + * Reports whether a request is in flight, so the caller can stop the row + * buttons from retargeting this dialog mid-publication. + */ + onBusyChange?: (busy: boolean) => void; + /** Promotes to the head of the queue - the caller's existing handler and toast. */ + onPromote: (repo: Repository) => Promise; + /** Called once the publication may have changed anything, so the caller refreshes. */ + onPublished: () => void | Promise; +} + +type Phase = 'idle' | 'promoting' | 'publishing' | 'done'; + +/** + * A safety net rather than a cancellation: aborting the request does not stop the + * run on the server, so the timeout has to sit well above the slowest legitimate + * publication (image generation plus the Threads connector's own 90 s timeout). + */ +const PUBLISH_TIMEOUT_MS = 240_000; + +const toneStyles = { + success: 'bg-success/20 text-success', + partial: 'bg-warning/10 text-warning', + error: 'bg-destructive/10 text-destructive', +} as const; + +export function PublishRepositoryDialog({ + repository, + isNext, + isApiReady, + integrations, + integrationsLoading = false, + onClose, + onBusyChange, + onPromote, + onPublished, +}: PublishRepositoryDialogProps) { + const [phase, setPhase] = useState('idle'); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(null); + // The repository the state on screen belongs to. One dialog instance serves + // every row, so a request that outlives its own target must not write its + // outcome into the dialog somebody has since pointed at another repository. + const targetIdRef = useRef(repository?.id ?? null); + + // Reopening on another row must never show the previous run's rows, and the + // request that filled them has to be dropped rather than left to land later. + useEffect(() => { + abortRef.current?.abort(); + abortRef.current = null; + targetIdRef.current = repository?.id ?? null; + setPhase('idle'); + setResult(null); + setError(null); + }, [repository?.id]); + + useEffect(() => () => abortRef.current?.abort(), []); + + const busy = phase === 'promoting' || phase === 'publishing'; + + useEffect(() => { + onBusyChange?.(busy); + }, [busy, onBusyChange]); + const rows = buildPublishRows(integrations, phase === 'idle' || phase === 'promoting' ? null : result); + const summary = result ? summarizePublishResult(result) : null; + + const handlePromote = async () => { + if (!repository || busy) return; + + setPhase('promoting'); + try { + await onPromote(repository); + onClose(); + } catch { + // The caller already reported it with a toast; the dialog stays open so the + // action can be retried or the other one picked instead. + } finally { + setPhase('idle'); + } + }; + + const handlePublishNow = async () => { + if (!repository || phase !== 'idle') return; + + const targetId = repository.id; + const controller = new AbortController(); + const timedOut = { value: false }; + const timeout = window.setTimeout(() => { + timedOut.value = true; + controller.abort(); + }, PUBLISH_TIMEOUT_MS); + abortRef.current = controller; + setPhase('publishing'); + setError(null); + let answered = false; + + try { + const publishResult = await publishMessageNow(repository.url, { signal: controller.signal }); + answered = true; + if (targetIdRef.current !== targetId) return; + setResult(publishResult); + if (publishedSomething(publishResult)) { + await onPublished(); + } + } catch (err) { + // Retargeting the dialog aborts this request on purpose; its outcome + // belongs to a repository nobody is looking at any more. + if (targetIdRef.current !== targetId) return; + setError( + timedOut.value + ? 'Content Maestro did not answer in time. The publication may still be running — check Cron History before trying again.' + : maestroErrorMessage(err) + ); + // The run may have published before the connection broke, so the list has + // to be refreshed either way. + await onPublished(); + } finally { + window.clearTimeout(timeout); + if (abortRef.current === controller) { + abortRef.current = null; + } + if (targetIdRef.current === targetId) { + // A request that never produced a result leaves the choices in place, with + // the error above them: refusals (a cron run holding the lock, an item + // already published) are the kind of thing that is worth another try, and + // the backend refuses a duplicate on its own. + setPhase(answered ? 'done' : 'idle'); + } + } + }; + + const renderRow = (row: PublishRow) => { + const flag = getLanguageFlag(row.textLanguage); + + return ( +
  • +
    + + {row.name} + {flag && } + + {row.state === 'pending' && phase === 'publishing' && ( + <> + + Sending... + + )} + {row.state === 'success' && ( + <> + + Sent + + )} + {row.state === 'failure' && ( + <> + + Failed + + )} + +
    + {row.state === 'failure' && row.error && ( + {row.error} + )} +
  • + ); + }; + + return ( + { + if (!open && phase !== 'publishing') onClose(); + }} + > + {/* + Only a publication in flight blocks dismissal: its per-integration result + exists nowhere else, so closing mid-run would throw it away. Promoting is + not blocked - it has no in-dialog result to lose, and the promote request + carries no timeout, so blocking it would make a stalled backend leave a + dialog that cannot be closed at all. + */} + phase === 'publishing' && event.preventDefault()} + onPointerDownOutside={event => phase === 'publishing' && event.preventDefault()} + > + + Publish repository + + {repository?.url.replace('https://github.com/', '')} + + + +
    + {!isApiReady && ( +

    + Content Maestro API is not configured — check Settings. +

    + )} + + {phase === 'idle' && isApiReady && ( +
    +

    + Publish next moves this + repository to the head of the queue; the scheduled run publishes it. +

    +

    + Publish now sends it + immediately to every enabled integration. +

    +
    + )} + + {summary && ( +

    {summary.text}

    + )} + + {error && ( +

    + + {error} +

    + )} + + {result?.posted_error && ( +

    + Published, but the repository could not be marked as posted — the scheduled run may + publish it again. ({result.posted_error}) +

    + )} + + {rows.length > 0 ? ( +
    +

    + {phase === 'done' ? 'Integrations:' : 'Will publish to:'} +

    +
      {rows.map(renderRow)}
    +
    + ) : !isApiReady ? null : integrationsLoading ? ( + // An empty list while the configs are still loading is not the same as + // no integration being enabled, and saying so would be wrong. +

    + + Loading integrations... +

    + ) : ( +

    + No integration is enabled, so there is nothing to publish to. Enable one on the + Integrations tab. +

    + )} +
    + + + {phase === 'done' ? ( + + ) : ( + <> + + + + + )} + +
    +
    + ); +} diff --git a/src/components/ui/business/repository-list.tsx b/src/components/ui/business/repository-list.tsx index bb8b08d..35d0273 100644 --- a/src/components/ui/business/repository-list.tsx +++ b/src/components/ui/business/repository-list.tsx @@ -1,5 +1,6 @@ // React imports // (no state needed since the Posts section is no longer collapsible) +import type { ApiConfig } from '@/api/api-configs'; import type { Repository, RepositorySortBy, RepositorySortOrder } from '@/types'; import { Filter, ChevronDown } from 'lucide-react'; import { filterRepositories, countActiveFilters } from '@/utils/repositoryListUtils'; @@ -17,6 +18,10 @@ interface RepositoryListProps { fetchRepositories: (posted?: boolean, append?: boolean, fetchAll?: boolean, itemsPerPage?: number, sortBy?: RepositorySortBy, sortOrder?: RepositorySortOrder, page?: number, forceFetch?: boolean) => Promise; fetchPreviews: (forceFetch?: boolean) => Promise; nextPostId?: number; + /** Integration configs, so the publish dialog can list the enabled ones. */ + integrations?: ApiConfig[]; + /** True while those configs are still being fetched. */ + integrationsLoading?: boolean; totalItems: number; totalPages: number; currentPage: number; @@ -31,6 +36,8 @@ export function RepositoryList({ fetchRepositories, fetchPreviews, nextPostId, + integrations, + integrationsLoading, totalItems, totalPages, currentPage: initialPage, @@ -139,6 +146,8 @@ export function RepositoryList({ itemsPerPage={itemsPerPage} searchTerm={searchTerm} nextPostId={nextPostId} + integrations={integrations} + integrationsLoading={integrationsLoading} onRepositoryUpdate={handleRepositoryUpdate} onRepositoryArchived={onRepositoryArchived} /> @@ -153,6 +162,8 @@ export function RepositoryList({ itemsPerPage={itemsPerPage} searchTerm={searchTerm} nextPostId={nextPostId} + integrations={integrations} + integrationsLoading={integrationsLoading} onRepositoryUpdate={handleRepositoryUpdate} onRepositoryArchived={onRepositoryArchived} /> diff --git a/src/components/ui/business/repository-mobile-view.tsx b/src/components/ui/business/repository-mobile-view.tsx index 05808a0..392f47c 100644 --- a/src/components/ui/business/repository-mobile-view.tsx +++ b/src/components/ui/business/repository-mobile-view.tsx @@ -8,6 +8,8 @@ import { Pencil, Check, X, Trash2, AlertCircle, Archive, Send } from 'lucide-rea import { describeArchiveFailure, isStaleArchiveFailure, summarizeArchiveResult } from '@/utils/archiveUtils'; import { toast } from '@/components/ui/common/toast-config'; import { ConfirmDialog } from '@/components/ui/common/confirm-dialog'; +import { PublishRepositoryDialog } from './publish-repository-dialog'; +import { enabledIntegrations } from '@/utils/message-publish'; import { RepositoryLink } from '@/components/ui/common/repository-link'; import { Button } from '../base/button'; import { Label } from '../base/label'; @@ -24,6 +26,8 @@ export function RepositoryMobileView({ itemsPerPage, searchTerm, nextPostId, + integrations, + integrationsLoading, onRepositoryUpdate, onRepositoryArchived }: RepositoryMobileViewProps) { @@ -34,7 +38,10 @@ export function RepositoryMobileView({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [showArchiveConfirm, setShowArchiveConfirm] = useState(null); const [archivingId, setArchivingId] = useState(null); - const [showPromoteConfirm, setShowPromoteConfirm] = useState(null); + const [publishTarget, setPublishTarget] = useState(null); + // One dialog instance serves every row, so retargeting it while it is working + // would leave a request writing its outcome into somebody else's repository. + const [publishBusy, setPublishBusy] = useState(false); const [promotingId, setPromotingId] = useState(null); const textInputRef = useRef(null); @@ -207,11 +214,25 @@ export function RepositoryMobileView({ }; const handlePromoteRepository = async (repo: Repository) => { - if (repo.posted || repo.id === nextPostId || promotingId !== null) return; + if (promotingId !== null) return; try { setPromotingId(repo.id); - await promoteRepositoryToNext({ id: repo.id }); + // nextPostId is a render-time value, so another tab - or the cron - can have + // moved the queue on since this row was drawn. Saying so beats closing the + // dialog as if a promotion that never happened had succeeded. + if (repo.posted) { + throw new Error('Repository is already published'); + } + if (repo.id === nextPostId) { + throw new Error('Repository is already next in the queue'); + } + const result = await promoteRepositoryToNext({ id: repo.id }); + // An unconfigured API answers with an error payload instead of throwing, and + // reporting success for a request that never left the browser is a lie. + if (result.status === 'error') { + throw new Error(result.message); + } toast.success(`Repository will be published next`, { ...toastOptions, @@ -227,6 +248,8 @@ export function RepositoryMobileView({ ...toastOptions, id: `promote-error-${repo.id}` }); + // Rethrown so the publish dialog keeps itself open on a failed promotion. + throw error; } finally { setPromotingId(null); } @@ -355,11 +378,11 @@ export function RepositoryMobileView({ @@ -459,20 +482,20 @@ export function RepositoryMobileView({ onCancel={() => setShowArchiveConfirm(null)} /> - { - if (showPromoteConfirm) { - handlePromoteRepository(showPromoteConfirm); + setPublishTarget(null)} + onBusyChange={setPublishBusy} + onPromote={handlePromoteRepository} + onPublished={async () => { + if (onRepositoryUpdate) { + await onRepositoryUpdate(); } - setShowPromoteConfirm(null); }} - onCancel={() => setShowPromoteConfirm(null)} /> ); diff --git a/src/components/ui/business/repository-table.tsx b/src/components/ui/business/repository-table.tsx index 992ba71..50944a2 100644 --- a/src/components/ui/business/repository-table.tsx +++ b/src/components/ui/business/repository-table.tsx @@ -7,6 +7,8 @@ import { Pencil, Check, X, Trash2, AlertCircle, Archive, ChevronDown, ChevronUp, import { describeArchiveFailure, isStaleArchiveFailure, summarizeArchiveResult } from '@/utils/archiveUtils'; import { toast } from '../common/toast-config'; import { ConfirmDialog } from '../common/confirm-dialog'; +import { PublishRepositoryDialog } from './publish-repository-dialog'; +import { enabledIntegrations } from '@/utils/message-publish'; import { Table, TableBody, @@ -72,6 +74,8 @@ export function RepositoryTable({ itemsPerPage, searchTerm, nextPostId, + integrations, + integrationsLoading, onRepositoryUpdate, onRepositoryArchived }: RepositoryTableProps) { @@ -82,7 +86,10 @@ export function RepositoryTable({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [showArchiveConfirm, setShowArchiveConfirm] = useState(null); const [archivingId, setArchivingId] = useState(null); - const [showPromoteConfirm, setShowPromoteConfirm] = useState(null); + const [publishTarget, setPublishTarget] = useState(null); + // One dialog instance serves every row, so retargeting it while it is working + // would leave a request writing its outcome into somebody else's repository. + const [publishBusy, setPublishBusy] = useState(false); const [promotingId, setPromotingId] = useState(null); const textInputRef = useRef(null); @@ -255,11 +262,25 @@ export function RepositoryTable({ }; const handlePromoteRepository = async (repo: Repository) => { - if (repo.posted || repo.id === nextPostId || promotingId !== null) return; + if (promotingId !== null) return; try { setPromotingId(repo.id); - await promoteRepositoryToNext({ id: repo.id }); + // nextPostId is a render-time value, so another tab - or the cron - can have + // moved the queue on since this row was drawn. Saying so beats closing the + // dialog as if a promotion that never happened had succeeded. + if (repo.posted) { + throw new Error('Repository is already published'); + } + if (repo.id === nextPostId) { + throw new Error('Repository is already next in the queue'); + } + const result = await promoteRepositoryToNext({ id: repo.id }); + // An unconfigured API answers with an error payload instead of throwing, and + // reporting success for a request that never left the browser is a lie. + if (result.status === 'error') { + throw new Error(result.message); + } toast.success(`Repository will be published next`, { ...toastOptions, @@ -275,6 +296,8 @@ export function RepositoryTable({ ...toastOptions, id: `promote-error-${repo.id}` }); + // Rethrown so the publish dialog keeps itself open on a failed promotion. + throw error; } finally { setPromotingId(null); } @@ -411,16 +434,18 @@ export function RepositoryTable({ - {repo.id === nextPostId ? 'Already next' : 'Publish next'} + + {repo.id === nextPostId ? 'Publish (already next in the queue)' : 'Publish'} + )} @@ -546,20 +571,20 @@ export function RepositoryTable({ onCancel={() => setShowArchiveConfirm(null)} /> - { - if (showPromoteConfirm) { - handlePromoteRepository(showPromoteConfirm); + setPublishTarget(null)} + onBusyChange={setPublishBusy} + onPromote={handlePromoteRepository} + onPublished={async () => { + if (onRepositoryUpdate) { + await onRepositoryUpdate(); } - setShowPromoteConfirm(null); }} - onCancel={() => setShowPromoteConfirm(null)} /> ); diff --git a/src/components/ui/layout/dashboard-content.tsx b/src/components/ui/layout/dashboard-content.tsx index 83673fa..36cfa42 100644 --- a/src/components/ui/layout/dashboard-content.tsx +++ b/src/components/ui/layout/dashboard-content.tsx @@ -408,6 +408,8 @@ export const DashboardContent = ({ fetchRepositories={fetchRepositories} fetchPreviews={fetchPreviews} nextPostId={nextPost?.id} + integrations={apiConfigs} + integrationsLoading={apiConfigsLoading} currentPage={pagination.currentPage} pageSize={pagination.pageSize} totalPages={pagination.totalPages} diff --git a/src/types/repositoryList.ts b/src/types/repositoryList.ts index 4771c91..f398005 100644 --- a/src/types/repositoryList.ts +++ b/src/types/repositoryList.ts @@ -1,3 +1,4 @@ +import type { ApiConfig } from '../api/api-configs'; import type { Repository, RepositorySortBy, RepositorySortOrder, RepositoryStatusFilter } from '../types'; export interface TruncatedTextProps { @@ -30,6 +31,10 @@ export interface RepositoryTableProps { itemsPerPage: number; searchTerm: string; nextPostId?: number; + /** Integration configs, so the publish dialog can list the enabled ones. */ + integrations?: ApiConfig[]; + /** True while those configs are still being fetched. */ + integrationsLoading?: boolean; onRepositoryUpdate?: () => void | Promise; onRepositoryArchived?: () => void | Promise; } @@ -42,6 +47,10 @@ export interface RepositoryMobileViewProps { itemsPerPage: number; searchTerm: string; nextPostId?: number; + /** Integration configs, so the publish dialog can list the enabled ones. */ + integrations?: ApiConfig[]; + /** True while those configs are still being fetched. */ + integrationsLoading?: boolean; onRepositoryUpdate?: () => void | Promise; onRepositoryArchived?: () => void | Promise; } diff --git a/src/utils/message-publish.test.ts b/src/utils/message-publish.test.ts new file mode 100644 index 0000000..7b18e37 --- /dev/null +++ b/src/utils/message-publish.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import type { ApiConfig } from '@/api/api-configs'; +import type { RetryMessageResult } from '@/api/index'; +import { + buildPublishRows, + enabledIntegrations, + publishedSomething, + summarizePublishResult, +} from './message-publish'; + +const config = (overrides: Partial): ApiConfig => ({ + id: 1, + name: 'threads', + url: 'http://threads-connector:9016/threads/post', + method: 'POST', + auth_type: 'api_key', + token_env_var: 'THREADS_API_KEY', + token_header: 'X-API-Key', + content_type: 'json', + timeout: 90, + success_code: 200, + enabled: true, + response_type: 'json', + text_language: 'en', + socialify_image: false, + default_json_body: '', + updated_at: '2026-08-17T16:00:00Z', + ...overrides, +}); + +const result = (overrides: Partial): RetryMessageResult => ({ + url: 'https://github.com/a/b', + status: 1, + message: 'Manual publish: https://github.com/a/b sent to: threads', + ...overrides, +}); + +const telegram = { name: 'telegram', textLanguage: 'uk' }; +const threads = { name: 'threads', textLanguage: 'en' }; + +describe('enabledIntegrations', () => { + it('keeps only the enabled ones, sorted the way Content Maestro reports them', () => { + expect(enabledIntegrations([ + config({ name: 'threads', text_language: 'en' }), + config({ name: 'bluesky', enabled: false }), + config({ name: 'telegram', text_language: 'uk' }), + ])).toEqual([telegram, threads]); + }); + + it('returns nothing when every integration is disabled', () => { + expect(enabledIntegrations([config({ enabled: false })])).toEqual([]); + }); +}); + +describe('buildPublishRows', () => { + it('shows every expected integration as pending before the run answers', () => { + expect(buildPublishRows([telegram, threads])).toEqual([ + { name: 'telegram', state: 'pending', textLanguage: 'uk' }, + { name: 'threads', state: 'pending', textLanguage: 'en' }, + ]); + }); + + it('resolves the rows from the reported outcomes', () => { + expect(buildPublishRows([telegram, threads], result({ + status: 2, + succeeded: ['telegram'], + failed: ['threads'], + outcomes: [ + { api_name: 'telegram', success: true }, + { api_name: 'threads', success: false, error: 'API request failed with status 500' }, + ], + }))).toEqual([ + { name: 'telegram', state: 'success', error: undefined, textLanguage: 'uk' }, + { name: 'threads', state: 'failure', error: 'API request failed with status 500', textLanguage: 'en' }, + ]); + }); + + // Go marshals empty slices as null, so the outcomes can be missing entirely + // while the name lists still say what happened. + it('falls back to the name lists when no outcomes came back', () => { + expect(buildPublishRows([telegram, threads], result({ + status: 2, + succeeded: ['telegram'], + failed: ['threads'], + outcomes: null, + }))).toEqual([ + { name: 'telegram', state: 'success', error: undefined, textLanguage: 'uk' }, + { name: 'threads', state: 'failure', error: 'unknown error', textLanguage: 'en' }, + ]); + }); + + it('names a failure with no message rather than leaving it blank', () => { + expect(buildPublishRows([threads], result({ + status: 0, + outcomes: [{ api_name: 'threads', success: false, error: ' ' }], + }))[0]).toEqual({ name: 'threads', state: 'failure', error: 'unknown error', textLanguage: 'en' }); + }); + + // An integration enabled in another tab is published to but is missing from + // this dashboard's cached list, so the run is what decides it exists. + it('includes an integration the run reported but the dashboard did not expect', () => { + expect(buildPublishRows([threads], result({ + succeeded: ['threads', 'bluesky'], + outcomes: [ + { api_name: 'threads', success: true }, + { api_name: 'bluesky', success: true }, + ], + }))).toEqual([ + { name: 'threads', state: 'success', error: undefined, textLanguage: 'en' }, + { name: 'bluesky', state: 'success', error: undefined, textLanguage: undefined }, + ]); + }); + + // The opposite case: an integration disabled since the list was cached never + // gets an outcome, and silently showing it as fine would be a lie. + it('treats an expected integration with no reported outcome as a failure', () => { + expect(buildPublishRows([telegram, threads], result({ + succeeded: ['threads'], + outcomes: [{ api_name: 'threads', success: true }], + }))).toEqual([ + { name: 'telegram', state: 'failure', error: 'no result reported', textLanguage: 'uk' }, + { name: 'threads', state: 'success', error: undefined, textLanguage: 'en' }, + ]); + }); +}); + +describe('summarizePublishResult', () => { + it('reports a full success', () => { + const summary = summarizePublishResult(result({ + status: 1, + succeeded: ['telegram', 'threads'], + posted: true, + })); + expect(summary.tone).toBe('success'); + expect(summary.text).toContain('all 2 integrations'); + }); + + it('reports a partial success and points at the retry', () => { + const summary = summarizePublishResult(result({ + status: 2, + succeeded: ['telegram'], + failed: ['threads'], + posted: true, + })); + expect(summary.tone).toBe('partial'); + expect(summary.text).toContain('1 of 2'); + expect(summary.text).toContain('Cron History'); + }); + + // Sending someone to "Publish again" for an item that never left the queue + // would have the scheduled run repost it to the integrations that already + // have it, so the queue sentence follows `posted` and not the counts. + it('does not claim the item left the queue when marking it posted failed', () => { + const summary = summarizePublishResult(result({ + status: 2, + succeeded: ['telegram'], + failed: ['threads'], + posted: false, + posted_error: 'context deadline exceeded', + })); + expect(summary.tone).toBe('partial'); + expect(summary.text).toContain('still in the queue'); + expect(summary.text).not.toContain('Cron History'); + }); + + it('downgrades an otherwise complete run that stayed in the queue', () => { + const summary = summarizePublishResult(result({ + status: 1, + succeeded: ['telegram', 'threads'], + posted: false, + posted_error: 'context deadline exceeded', + })); + expect(summary.tone).toBe('partial'); + expect(summary.text).toContain('still in the queue'); + }); + + it('reports a run that published nothing and says the item stays queued', () => { + const summary = summarizePublishResult(result({ status: 0, succeeded: null, failed: ['threads'] })); + expect(summary.tone).toBe('error'); + expect(summary.text).toContain('stays in the queue'); + }); +}); + +describe('publishedSomething', () => { + it.each([ + ['no result', undefined, false], + ['a null list', result({ succeeded: null }), false], + ['an empty list', result({ succeeded: [] }), false], + ['one integration', result({ succeeded: ['threads'] }), true], + ])('%s', (_name, value, expected) => { + expect(publishedSomething(value as RetryMessageResult | undefined)).toBe(expected); + }); +}); diff --git a/src/utils/message-publish.ts b/src/utils/message-publish.ts new file mode 100644 index 0000000..7a66d35 --- /dev/null +++ b/src/utils/message-publish.ts @@ -0,0 +1,141 @@ +import type { ApiConfig } from '../api/api-configs'; +import type { RetryMessageResult } from '../api/index'; + +export interface PublishIntegration { + name: string; + textLanguage?: string; +} + +export type PublishRowState = 'pending' | 'success' | 'failure'; + +export interface PublishRow { + name: string; + state: PublishRowState; + error?: string; + textLanguage?: string; +} + +export interface PublishSummary { + tone: 'success' | 'partial' | 'error'; + text: string; +} + +/** + * The integrations a publish-now will reach, in the order Content Maestro reports + * them (it sorts by name, because its own configuration is a map). Matching that + * order keeps the list the dialog shows before the run identical to the list of + * results it shows afterwards. + */ +export const enabledIntegrations = (configs: ApiConfig[]): PublishIntegration[] => + configs + .filter(config => config.enabled) + .map(config => ({ name: config.name, textLanguage: config.text_language })) + .sort((a, b) => a.name.localeCompare(b.name)); + +/** + * Merges what the dashboard expected with what the run reported. + * + * Content Maestro is authoritative: it resolves the enabled integrations at + * request time, so a run can report an integration this dashboard's cached list + * does not know about (enabled in another tab) and can omit one the cached list + * still carries (disabled since). Rows are therefore built from both, and an + * expected integration with no reported outcome is a failure rather than a + * silent success. + */ +export const buildPublishRows = ( + expected: PublishIntegration[], + result?: RetryMessageResult | null +): PublishRow[] => { + const languages = new Map(expected.map(integration => [integration.name, integration.textLanguage])); + + if (!result) { + return expected.map(integration => ({ + name: integration.name, + state: 'pending', + textLanguage: integration.textLanguage, + })); + } + + const rows = new Map(); + + const record = (name: string, state: PublishRowState, error?: string) => { + rows.set(name, { name, state, error, textLanguage: languages.get(name) }); + }; + + const outcomes = result.outcomes ?? []; + if (outcomes.length > 0) { + for (const outcome of outcomes) { + if (outcome.success) { + record(outcome.api_name, 'success'); + } else { + record(outcome.api_name, 'failure', outcome.error?.trim() || 'unknown error'); + } + } + } else { + // Go marshals empty slices as null, so a run can come back with the name + // lists filled in and no outcomes at all. + for (const name of result.succeeded ?? []) { + record(name, 'success'); + } + for (const name of result.failed ?? []) { + record(name, 'failure', 'unknown error'); + } + } + + for (const integration of expected) { + if (!rows.has(integration.name)) { + record(integration.name, 'failure', 'no result reported'); + } + } + + // Expected integrations first, in their own order; anything the run added on + // top of them goes after, so the list the user was looking at does not jump. + const ordered = expected + .map(integration => rows.get(integration.name)) + .filter((row): row is PublishRow => row !== undefined); + const extras = [...rows.values()].filter(row => !languages.has(row.name)); + + return [...ordered, ...extras]; +}; + +/** + * Wording and tone of the banner above the result rows. + * + * What it says about the queue follows `posted`, not the success counts: marking + * the repository as published can fail on its own, and telling someone the item + * left the queue when it did not would send them to "Publish again" for a repost + * the scheduled run is about to do anyway. + */ +export const summarizePublishResult = (result: RetryMessageResult): PublishSummary => { + const succeeded = result.succeeded ?? []; + const failed = result.failed ?? []; + + if (succeeded.length === 0) { + return { + tone: 'error', + text: 'Not published: no integration accepted the post. The repository stays in the queue.', + }; + } + + if (failed.length > 0) { + return { + tone: 'partial', + text: `Published to ${succeeded.length} of ${succeeded.length + failed.length} integrations. ` + + (result.posted + ? 'The repository left the queue, so finish the rest with "Publish again" in Cron History.' + : 'The repository is still in the queue, so the scheduled run will publish it again.'), + }; + } + + const reach = succeeded.length === 1 ? 'the integration' : `all ${succeeded.length} integrations`; + + return { + tone: result.posted === false ? 'partial' : 'success', + text: `Published to ${reach}.` + + (result.posted === false ? ' The repository is still in the queue, so the scheduled run will publish it again.' : ''), + }; +}; + +/** True when at least one integration accepted the post, so the parent must refresh. */ +export const publishedSomething = (result?: RetryMessageResult | null): boolean => + (result?.succeeded ?? []).length > 0;