diff --git a/.github/hooks/post-edit-invalidate.sh b/.github/hooks/post-edit-invalidate.sh old mode 100644 new mode 100755 diff --git a/.github/hooks/pre-amend-block.sh b/.github/hooks/pre-amend-block.sh old mode 100644 new mode 100755 diff --git a/.github/hooks/pre-commit-block.sh b/.github/hooks/pre-commit-block.sh old mode 100644 new mode 100755 diff --git a/.github/hooks/pre-force-push-block.sh b/.github/hooks/pre-force-push-block.sh old mode 100644 new mode 100755 diff --git a/.github/hooks/pre-layer-import.sh b/.github/hooks/pre-layer-import.sh old mode 100644 new mode 100755 diff --git a/.github/hooks/pre-layer-mock.sh b/.github/hooks/pre-layer-mock.sh old mode 100644 new mode 100755 diff --git a/.github/hooks/pre-push-block.sh b/.github/hooks/pre-push-block.sh old mode 100644 new mode 100755 diff --git a/.github/hooks/pre-reexport-block.sh b/.github/hooks/pre-reexport-block.sh old mode 100644 new mode 100755 diff --git a/src/L3-services/azureStorage/azureStorageService.ts b/src/L3-services/azureStorage/azureStorageService.ts index 39faeaee..902a14a7 100644 --- a/src/L3-services/azureStorage/azureStorageService.ts +++ b/src/L3-services/azureStorage/azureStorageService.ts @@ -45,6 +45,21 @@ export interface ContentRecord { publishedAt: string } +export interface UploadedRawVideo { + blobPath: string + url: string +} + +export interface UploadedContentAsset { + itemId: string + clipType: 'video' | 'short' | 'medium-clip' + ideaIds: string[] + blobBasePath: string + cloudUrl?: string + thumbnailUrl?: string + clipSlug?: string +} + export async function uploadVideoFile( localPath: string, blobPath: string, @@ -78,11 +93,11 @@ export async function uploadRawVideo( duration?: number size: number }, -): Promise { +): Promise { const blobPath = `raw/${runId}-${metadata.originalFilename}` logger.info(`Uploading raw video to Azure: ${blobPath}`) - await blobClient.uploadFile(blobPath, localPath, 'video/mp4') + const url = await blobClient.uploadFile(blobPath, localPath, 'video/mp4') await tableClient.upsertEntity(VIDEOS_TABLE, 'video', runId, { originalFilename: metadata.originalFilename, @@ -98,7 +113,7 @@ export async function uploadRawVideo( } satisfies VideoRecord) logger.info(`Created video record: ${runId}`) - return blobPath + return { blobPath, url } } export async function uploadContentItem( @@ -107,8 +122,9 @@ export async function uploadContentItem( videoSlug: string, runId: string, metadata?: Partial, -): Promise { +): Promise { const blobBasePath = `content/${itemId}/` + const uploadedUrls = new Map() // Upload all files in the item directory const files = await readdir(localItemDir) @@ -116,7 +132,7 @@ export async function uploadContentItem( const localFilePath = join(localItemDir, file) const blobPath = `${blobBasePath}${file}` const contentType = getContentType(file) - await blobClient.uploadFile(blobPath, localFilePath, contentType) + uploadedUrls.set(file, await blobClient.uploadFile(blobPath, localFilePath, contentType)) } // Read metadata.json if exists to populate table record @@ -141,10 +157,19 @@ export async function uploadContentItem( // Determine media file const mediaFilename = files.find(f => f.startsWith('media.')) || '' const thumbnailFilename = files.find(f => f.startsWith('thumbnail.')) || '' + const clipType = String(itemMetadata.clipType || metadata?.clipType || '') + const ideaIds = Array.isArray(itemMetadata.ideaIds) + ? (itemMetadata.ideaIds as string[]).map((id) => String(id)).filter(Boolean) + : typeof metadata?.ideaIds === 'string' + ? metadata.ideaIds.split(',').map((id) => id.trim()).filter(Boolean) + : [] + const clipSlug = typeof itemMetadata.sourceClip === 'string' && itemMetadata.sourceClip.trim().length > 0 + ? basename(itemMetadata.sourceClip) + : undefined const record: ContentRecord = { platform: String(itemMetadata.platform || metadata?.platform || ''), - clipType: String(itemMetadata.clipType || metadata?.clipType || ''), + clipType, status: metadata?.status || 'pending_review', blobBasePath, mediaType: String(itemMetadata.mediaType || metadata?.mediaType || 'video'), @@ -157,7 +182,7 @@ export async function uploadContentItem( publishedUrl: String(itemMetadata.publishedUrl || metadata?.publishedUrl || ''), sourceVideoRunId: runId, thumbnailFilename, - ideaIds: Array.isArray(itemMetadata.ideaIds) ? (itemMetadata.ideaIds as string[]).join(',') : (metadata?.ideaIds || ''), + ideaIds: ideaIds.join(','), createdAt: String(itemMetadata.createdAt || new Date().toISOString()), reviewedAt: String(itemMetadata.reviewedAt || metadata?.reviewedAt || ''), publishedAt: String(itemMetadata.publishedAt || metadata?.publishedAt || ''), @@ -166,15 +191,24 @@ export async function uploadContentItem( await tableClient.upsertEntity(CONTENT_TABLE, videoSlug, itemId, record) logger.info(`Uploaded content item: ${itemId} (${record.platform}/${record.clipType}) — blob + table record created`) - return blobBasePath + return { + itemId, + clipType: isClipType(clipType) ? clipType : 'video', + ideaIds, + blobBasePath, + cloudUrl: mediaFilename ? uploadedUrls.get(mediaFilename) : undefined, + thumbnailUrl: thumbnailFilename ? uploadedUrls.get(thumbnailFilename) : undefined, + clipSlug, + } } export async function uploadPublishQueue( publishQueueDir: string, videoSlug: string, runId: string, -): Promise<{ uploaded: number; errors: string[] }> { +): Promise<{ uploaded: number; errors: string[]; assets: UploadedContentAsset[] }> { const errors: string[] = [] + const assets: UploadedContentAsset[] = [] let uploaded = 0 let items: string[] @@ -182,7 +216,7 @@ export async function uploadPublishQueue( items = await readdir(publishQueueDir) } catch { logger.warn(`Publish queue directory not found: ${publishQueueDir}`) - return { uploaded: 0, errors: ['Publish queue directory not found'] } + return { uploaded: 0, errors: ['Publish queue directory not found'], assets: [] } } for (const itemId of items) { @@ -204,7 +238,8 @@ export async function uploadPublishQueue( } try { - await uploadContentItem(itemDir, itemId, videoSlug, runId) + const uploadedAsset = await uploadContentItem(itemDir, itemId, videoSlug, runId) + assets.push(uploadedAsset) uploaded++ } catch (error: unknown) { const msg = error instanceof Error ? error.message : String(error) @@ -219,7 +254,7 @@ export async function uploadPublishQueue( }) logger.info(`Uploaded ${uploaded} content items to Azure (${errors.length} errors)`) - return { uploaded, errors } + return { uploaded, errors, assets } } export async function migrateLocalContent( @@ -390,3 +425,7 @@ function extractVideoSlug(itemId: string): string { } return itemId } + +function isClipType(value: string): value is UploadedContentAsset['clipType'] { + return value === 'video' || value === 'short' || value === 'medium-clip' +} diff --git a/src/L3-services/ideaService/ideaService.ts b/src/L3-services/ideaService/ideaService.ts index f2383882..fded416e 100644 --- a/src/L3-services/ideaService/ideaService.ts +++ b/src/L3-services/ideaService/ideaService.ts @@ -26,6 +26,20 @@ const ideaStatuses = new Set(['draft', 'ready', 'recorded', 'publish type IdeaPriority = IdeaFilters['priority'] +interface CloudAssetRecord { + assetKey: string + clipType: IdeaPublishRecord['clipType'] + sourceVideoSlug: string + clipSlug?: string + cloudUrl?: string + thumbnailUrl?: string + uploadedAt: string +} + +type ParsedIdeaComment = + | IdeaCommentData + | { type: 'cloud-asset'; asset: CloudAssetRecord } + interface IdeaBodyData { hook: string audience: string @@ -215,7 +229,7 @@ function formatIdeaComment(data: IdeaCommentData): string { } function formatPublishRecordComment(record: IdeaPublishRecord): string { - return [ + const lines = [ 'Published content recorded for this idea.', '', `- Clip type: ${record.clipType}`, @@ -224,9 +238,14 @@ function formatPublishRecordComment(record: IdeaPublishRecord): string { `- Published at: ${record.publishedAt}`, `- Late post ID: ${record.latePostId}`, `- Late URL: ${record.lateUrl}`, - '', - formatIdeaComment({ type: 'publish-record', record }), - ].join('\n') + ] + + if (record.publishedUrl) { + lines.push(`- Published URL: ${record.publishedUrl}`) + } + + lines.push('', formatIdeaComment({ type: 'publish-record', record })) + return lines.join('\n') } function formatVideoLinkComment(videoSlug: string, linkedAt: string): string { @@ -240,7 +259,44 @@ function formatVideoLinkComment(videoSlug: string, linkedAt: string): string { ].join('\n') } -function parseIdeaComment(commentBody: string): IdeaCommentData | null { +function formatCloudAssetComment(asset: CloudAssetRecord): string { + const lines = [ + 'Cloud media uploaded for this idea.', + '', + `- Asset key: ${asset.assetKey}`, + `- Clip type: ${asset.clipType}`, + `- Source video: ${asset.sourceVideoSlug}`, + ] + + if (asset.clipSlug) { + lines.push(`- Clip slug: ${asset.clipSlug}`) + } + if (asset.cloudUrl) { + lines.push(`- Cloud URL: ${asset.cloudUrl}`) + } + if (asset.thumbnailUrl) { + lines.push(`- Thumbnail URL: ${asset.thumbnailUrl}`) + } + + lines.push( + `- Uploaded at: ${asset.uploadedAt}`, + '', + [ + COMMENT_MARKER, + '```json', + JSON.stringify({ type: 'cloud-asset', asset }, null, 2), + '```', + ].join('\n'), + ) + + if (asset.thumbnailUrl) { + lines.push('', `![${asset.clipSlug ?? asset.sourceVideoSlug} thumbnail](${asset.thumbnailUrl})`) + } + + return lines.join('\n') +} + +function parseIdeaComment(commentBody: string): ParsedIdeaComment | null { const markerIndex = commentBody.indexOf(COMMENT_MARKER) if (markerIndex === -1) { return null @@ -254,9 +310,10 @@ function parseIdeaComment(commentBody: string): IdeaCommentData | null { } try { - const parsed = JSON.parse(jsonText) as Partial & { + const parsed = JSON.parse(jsonText) as { type?: string record?: Partial + asset?: Partial videoSlug?: unknown linkedAt?: unknown } @@ -289,6 +346,34 @@ function parseIdeaComment(commentBody: string): IdeaCommentData | null { publishedAt: record.publishedAt, latePostId: record.latePostId, lateUrl: record.lateUrl, + publishedUrl: typeof record.publishedUrl === 'string' ? record.publishedUrl : undefined, + }, + } + } + } + + if (parsed.type === 'cloud-asset' && parsed.asset) { + const asset = parsed.asset + if ( + typeof asset.assetKey === 'string' + && typeof asset.clipType === 'string' + && (asset.clipType === 'video' || asset.clipType === 'short' || asset.clipType === 'medium-clip') + && typeof asset.sourceVideoSlug === 'string' + && typeof asset.uploadedAt === 'string' + && (asset.clipSlug === undefined || typeof asset.clipSlug === 'string') + && (asset.cloudUrl === undefined || typeof asset.cloudUrl === 'string') + && (asset.thumbnailUrl === undefined || typeof asset.thumbnailUrl === 'string') + ) { + return { + type: 'cloud-asset', + asset: { + assetKey: asset.assetKey, + clipType: asset.clipType, + sourceVideoSlug: asset.sourceVideoSlug, + clipSlug: asset.clipSlug, + cloudUrl: asset.cloudUrl, + thumbnailUrl: asset.thumbnailUrl, + uploadedAt: asset.uploadedAt, }, } } @@ -362,7 +447,9 @@ function mapIssueToIdea(issue: GitHubIssue, comments: GitHubComment[]): Idea { continue } - sourceVideoSlug = parsedComment.videoSlug + if (parsedComment.type === 'video-link') { + sourceVideoSlug = parsedComment.videoSlug + } } return { @@ -611,6 +698,26 @@ export async function recordPublish(issueNumber: number, record: IdeaPublishReco } } +export async function recordCloudAsset(issueNumber: number, asset: CloudAssetRecord): Promise { + const client = getGitHubClient() + + try { + const comments = await client.listComments(issueNumber) + const hasDuplicate = comments.some((comment) => { + const parsedComment = parseIdeaComment(comment.body) + return parsedComment?.type === 'cloud-asset' && parsedComment.asset.assetKey === asset.assetKey + }) + + if (!hasDuplicate) { + await client.addComment(issueNumber, formatCloudAssetComment(asset)) + } + } catch (error: unknown) { + const message = getErrorMessage(error) + logger.error(`[IdeaService] Failed to record cloud asset for idea #${issueNumber}: ${message}`) + throw new Error(`Failed to record cloud asset for idea #${issueNumber}: ${message}`) + } +} + export async function getPublishHistory(issueNumber: number): Promise { const client = getGitHubClient() diff --git a/src/L3-services/postStore/postStore.ts b/src/L3-services/postStore/postStore.ts index c08aaa99..2fbf3956 100644 --- a/src/L3-services/postStore/postStore.ts +++ b/src/L3-services/postStore/postStore.ts @@ -379,6 +379,7 @@ export async function approveItem( publishedAt: now, latePostId: item.metadata.latePostId ?? '', lateUrl: item.metadata.publishedUrl || (item.metadata.latePostId ? `https://app.late.co/dashboard/post/${item.metadata.latePostId}` : ''), + publishedUrl: item.metadata.publishedUrl ?? undefined, }) } } catch (err: unknown) { diff --git a/src/L4-agents/cloudStorage/cloudStorageOperations.ts b/src/L4-agents/cloudStorage/cloudStorageOperations.ts index 97e53013..c6b2015d 100644 --- a/src/L4-agents/cloudStorage/cloudStorageOperations.ts +++ b/src/L4-agents/cloudStorage/cloudStorageOperations.ts @@ -16,19 +16,28 @@ export async function uploadPipelineResults( duration?: number size: number }, -): Promise<{ runId: string; videoUploaded: boolean; contentUploaded: number; errors: string[] }> { +): Promise<{ + runId: string + videoUploaded: boolean + contentUploaded: number + errors: string[] + videoUrl?: string + assets: azureStorageService.UploadedContentAsset[] +}> { const runId = azureStorageService.getRunId() + let videoUrl: string | undefined logger.info(`Cloud upload starting (runId: ${runId})`) // Upload raw video let videoUploaded = false try { - await azureStorageService.uploadRawVideo(inputVideoPath, runId, { + const uploadedVideo = await azureStorageService.uploadRawVideo(inputVideoPath, runId, { ...metadata, slug: videoSlug, }) videoUploaded = true + videoUrl = uploadedVideo.url } catch (error: unknown) { const msg = error instanceof Error ? error.message : String(error) logger.error(`Failed to upload raw video: ${msg}`) @@ -44,6 +53,8 @@ export async function uploadPipelineResults( videoUploaded, contentUploaded: result.uploaded, errors: result.errors, + videoUrl, + assets: result.assets, } } diff --git a/src/L5-assets/bridges/cloudStorageBridge.ts b/src/L5-assets/bridges/cloudStorageBridge.ts index ffb5bc9d..6140c274 100644 --- a/src/L5-assets/bridges/cloudStorageBridge.ts +++ b/src/L5-assets/bridges/cloudStorageBridge.ts @@ -8,7 +8,22 @@ export async function uploadToCloud( duration?: number size: number }, -): Promise<{ runId: string; videoUploaded: boolean; contentUploaded: number; errors: string[] }> { +): Promise<{ + runId: string + videoUploaded: boolean + contentUploaded: number + errors: string[] + videoUrl?: string + assets: Array<{ + itemId: string + clipType: 'video' | 'short' | 'medium-clip' + ideaIds: string[] + blobBasePath: string + cloudUrl?: string + thumbnailUrl?: string + clipSlug?: string + }> +}> { const { uploadPipelineResults } = await import('../../L4-agents/cloudStorage/cloudStorageOperations.js') return uploadPipelineResults(inputVideoPath, publishQueueDir, videoSlug, metadata) } diff --git a/src/L6-pipeline/pipeline.ts b/src/L6-pipeline/pipeline.ts index ad0337b5..9bc5ddd8 100644 --- a/src/L6-pipeline/pipeline.ts +++ b/src/L6-pipeline/pipeline.ts @@ -27,6 +27,8 @@ import type { ShortVideoAsset } from '../L5-assets/ShortVideoAsset.js' import type { MediumClipAsset } from '../L5-assets/MediumClipAsset.js' import type { CaptionFiles } from '../L5-assets/VideoAsset.js' +type CloudUploadResult = Awaited> + /** * Execute a single pipeline stage with error isolation and timing. * @@ -481,6 +483,8 @@ export async function processVideo(videoPath: string, ideas?: Idea[], publishBy? if (result.errors.length > 0) { logger.warn(`Cloud upload had ${result.errors.length} error(s): ${result.errors.join('; ')}`) } + + await syncCloudAssetsToIdeas(video, asset.ideas, result) }) const totalDuration = Date.now() - pipelineStart @@ -533,6 +537,56 @@ export async function processVideo(videoPath: string, ideas?: Idea[], publishBy? } } +async function syncCloudAssetsToIdeas( + video: Awaited>, + ideas: Idea[] | undefined, + uploadResult: CloudUploadResult, +): Promise { + const { recordCloudAsset } = await import('../L3-services/ideaService/ideaService.js') + const uploadedAt = new Date().toISOString() + const uniqueVideoIdeaNumbers = Array.from(new Set( + (ideas ?? []) + .map((idea) => idea.issueNumber) + .filter((issueNumber) => Number.isInteger(issueNumber) && issueNumber > 0), + )) + + if (uploadResult.videoUrl) { + for (const issueNumber of uniqueVideoIdeaNumbers) { + await recordCloudAsset(issueNumber, { + assetKey: `video:${video.slug}`, + clipType: 'video', + sourceVideoSlug: video.slug, + cloudUrl: uploadResult.videoUrl, + uploadedAt, + }) + } + } + + for (const asset of uploadResult.assets) { + if (!asset.cloudUrl && !asset.thumbnailUrl) { + continue + } + + const issueNumbers = Array.from(new Set( + asset.ideaIds + .map((ideaId) => Number.parseInt(ideaId, 10)) + .filter((issueNumber) => Number.isInteger(issueNumber) && issueNumber > 0), + )) + + for (const issueNumber of issueNumbers) { + await recordCloudAsset(issueNumber, { + assetKey: `queue:${asset.itemId}`, + clipType: asset.clipType, + sourceVideoSlug: video.slug, + clipSlug: asset.clipSlug, + cloudUrl: asset.cloudUrl, + thumbnailUrl: asset.thumbnailUrl, + uploadedAt, + }) + } + } +} + function generateCostMarkdown(report: CostReport): string { let md = '# Pipeline Cost Report\n\n' md += `| Metric | Value |\n|--------|-------|\n` diff --git a/src/__tests__/unit/L3-services/azureStorage.test.ts b/src/__tests__/unit/L3-services/azureStorage.test.ts index 94ed06a4..e62e1c51 100644 --- a/src/__tests__/unit/L3-services/azureStorage.test.ts +++ b/src/__tests__/unit/L3-services/azureStorage.test.ts @@ -81,13 +81,16 @@ describe('L3 Unit: Azure Storage Service', () => { }) test('uploadRawVideo uploads file and creates table record', async () => { - const blobPath = await uploadRawVideo('/videos/video.mp4', 'run-123', { + const uploadedVideo = await uploadRawVideo('/videos/video.mp4', 'run-123', { originalFilename: 'video.mp4', slug: 'my-video', size: 1024000, }) - expect(blobPath).toBe('raw/run-123-video.mp4') + expect(uploadedVideo).toEqual({ + blobPath: 'raw/run-123-video.mp4', + url: 'https://blob.url', + }) expect(mockUploadFile).toHaveBeenCalledWith('raw/run-123-video.mp4', '/videos/video.mp4', 'video/mp4') expect(mockUpsertEntity).toHaveBeenCalledWith('Videos', 'video', 'run-123', expect.objectContaining({ originalFilename: 'video.mp4', @@ -207,7 +210,14 @@ describe('L3 Unit: Azure Storage Service', () => { const result = await uploadContentItem('/items/item-1', 'item-1', 'my-video', 'run-1') - expect(result).toBe('content/item-1/') + expect(result).toEqual(expect.objectContaining({ + itemId: 'item-1', + clipType: 'short', + ideaIds: ['idea-1'], + blobBasePath: 'content/item-1/', + cloudUrl: 'https://blob.url', + thumbnailUrl: 'https://blob.url', + })) // Uploads 4 files expect(mockUploadFile).toHaveBeenCalledTimes(4) expect(mockUploadFile).toHaveBeenCalledWith('content/item-1/media.mp4', expect.any(String), 'video/mp4') @@ -239,7 +249,11 @@ describe('L3 Unit: Azure Storage Service', () => { clipType: 'short', }) - expect(result).toBe('content/item-2/') + expect(result).toEqual(expect.objectContaining({ + itemId: 'item-2', + clipType: 'short', + blobBasePath: 'content/item-2/', + })) expect(mockUpsertEntity).toHaveBeenCalledWith('Content', 'my-video', 'item-2', expect.objectContaining({ platform: 'tiktok', clipType: 'short', @@ -309,6 +323,7 @@ describe('L3 Unit: Azure Storage Service', () => { expect(result.uploaded).toBe(2) // item-other skipped expect(result.errors).toHaveLength(0) + expect(result.assets).toHaveLength(2) expect(mockUpdateEntity).toHaveBeenCalledWith('Videos', 'video', 'run-1', { contentCount: 2 }) }) @@ -319,6 +334,7 @@ describe('L3 Unit: Azure Storage Service', () => { const result = await uploadPublishQueue('/queue', 'my-video', 'run-1') expect(result.uploaded).toBe(0) + expect(result.assets).toEqual([]) }) test('handles missing publish queue directory', async () => { @@ -328,6 +344,7 @@ describe('L3 Unit: Azure Storage Service', () => { expect(result.uploaded).toBe(0) expect(result.errors).toEqual(['Publish queue directory not found']) + expect(result.assets).toEqual([]) }) test('captures errors for individual items', async () => { @@ -347,6 +364,7 @@ describe('L3 Unit: Azure Storage Service', () => { expect(result.uploaded).toBe(1) expect(result.errors).toHaveLength(1) + expect(result.assets).toHaveLength(1) expect(result.errors[0]).toContain('item-bad') expect(result.errors[0]).toContain('Permission denied') }) diff --git a/src/__tests__/unit/L3-services/ideaServiceGithub.test.ts b/src/__tests__/unit/L3-services/ideaServiceGithub.test.ts index 93e3dfb3..20be82d4 100644 --- a/src/__tests__/unit/L3-services/ideaServiceGithub.test.ts +++ b/src/__tests__/unit/L3-services/ideaServiceGithub.test.ts @@ -38,6 +38,7 @@ import { listIdeas, markPublished, markRecorded, + recordCloudAsset, recordPublish, searchIdeas, updateIdea, @@ -121,6 +122,7 @@ function createPublishRecord(overrides: Partial = {}): IdeaPu publishedAt: overrides.publishedAt ?? '2026-02-20T12:00:00.000Z', latePostId: overrides.latePostId ?? 'late-123', lateUrl: overrides.lateUrl ?? 'https://late.example/posts/late-123', + publishedUrl: overrides.publishedUrl, } } @@ -139,7 +141,7 @@ function createVideoLinkComment(videoSlug = 'video-debug-loop'): string { } function createPublishComment(record: IdeaPublishRecord): string { - return [ + const lines = [ 'Published content recorded for this idea.', '', `- Clip type: ${record.clipType}`, @@ -148,12 +150,20 @@ function createPublishComment(record: IdeaPublishRecord): string { `- Published at: ${record.publishedAt}`, `- Late post ID: ${record.latePostId}`, `- Late URL: ${record.lateUrl}`, + ] + + if (record.publishedUrl) { + lines.push(`- Published URL: ${record.publishedUrl}`) + } + + lines.push( '', '', '```json', JSON.stringify({ type: 'publish-record', record }, null, 2), '```', - ].join('\n') + ) + return lines.join('\n') } describe('ideaService GitHub integration', () => { @@ -225,7 +235,7 @@ describe('ideaService GitHub integration', () => { }) it('ideaService.REQ-003 ideaService.REQ-014 ideaService.REQ-015 - getIdea reconstructs the full idea from issue body, labels, and structured comments', async () => { - const publishRecord = createPublishRecord() + const publishRecord = createPublishRecord({ publishedUrl: 'https://youtube.com/watch?v=abc123' }) mockGitHubClient.getIssue.mockResolvedValue(createIssue()) mockGitHubClient.listComments.mockResolvedValue([ createComment({ id: 1, body: createVideoLinkComment('video-debug-loop') }), @@ -374,7 +384,7 @@ describe('ideaService GitHub integration', () => { }) it('ideaService.REQ-009 ideaService.REQ-014 - recordPublish adds a structured publish-record comment when needed and ensures the idea is labeled published', async () => { - const publishRecord = createPublishRecord({ queueItemId: 'queue-new' }) + const publishRecord = createPublishRecord({ queueItemId: 'queue-new', publishedUrl: 'https://youtube.com/watch?v=queue-new' }) mockGitHubClient.getIssue.mockResolvedValue(createIssue({ labels: ['status:recorded', 'platform:youtube', 'copilot'] })) mockGitHubClient.listComments.mockResolvedValue([]) @@ -382,11 +392,70 @@ describe('ideaService GitHub integration', () => { expect(mockGitHubClient.addComment).toHaveBeenCalledWith(42, expect.stringContaining('')) expect(mockGitHubClient.addComment).toHaveBeenCalledWith(42, expect.stringContaining('"type": "publish-record"')) + expect(mockGitHubClient.addComment).toHaveBeenCalledWith(42, expect.stringContaining('Published URL: https://youtube.com/watch?v=queue-new')) expect(mockGitHubClient.updateIssue).toHaveBeenCalledWith(42, expect.objectContaining({ labels: expect.arrayContaining(['status:published', 'platform:youtube', 'copilot']), })) }) + it('ideaService.REQ-017 - recordCloudAsset adds a deduplicated structured cloud asset comment with thumbnail preview', async () => { + mockGitHubClient.listComments + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + createComment({ + body: [ + 'Cloud media uploaded for this idea.', + '', + '- Asset key: queue:clip-1-youtube', + '- Clip type: short', + '- Source video: source-video', + '- Cloud URL: https://blob.example/media.mp4', + '- Thumbnail URL: https://blob.example/thumbnail.png', + '- Uploaded at: 2026-02-21T12:00:00.000Z', + '', + '', + '```json', + JSON.stringify({ + type: 'cloud-asset', + asset: { + assetKey: 'queue:clip-1-youtube', + clipType: 'short', + sourceVideoSlug: 'source-video', + cloudUrl: 'https://blob.example/media.mp4', + thumbnailUrl: 'https://blob.example/thumbnail.png', + uploadedAt: '2026-02-21T12:00:00.000Z', + }, + }, null, 2), + '```', + ].join('\n'), + }), + ]) + + await recordCloudAsset(42, { + assetKey: 'queue:clip-1-youtube', + clipType: 'short', + sourceVideoSlug: 'source-video', + clipSlug: 'clip-1', + cloudUrl: 'https://blob.example/media.mp4', + thumbnailUrl: 'https://blob.example/thumbnail.png', + uploadedAt: '2026-02-21T12:00:00.000Z', + }) + + expect(mockGitHubClient.addComment).toHaveBeenCalledWith(42, expect.stringContaining('"type": "cloud-asset"')) + expect(mockGitHubClient.addComment).toHaveBeenCalledWith(42, expect.stringContaining('![clip-1 thumbnail](https://blob.example/thumbnail.png)')) + + await recordCloudAsset(42, { + assetKey: 'queue:clip-1-youtube', + clipType: 'short', + sourceVideoSlug: 'source-video', + cloudUrl: 'https://blob.example/media.mp4', + thumbnailUrl: 'https://blob.example/thumbnail.png', + uploadedAt: '2026-02-21T12:00:00.000Z', + }) + + expect(mockGitHubClient.addComment).toHaveBeenCalledTimes(1) + }) + it('ideaService.REQ-011 - getReadyIdeas, markRecorded, and markPublished delegate to list and lifecycle helpers', async () => { const publishRecord = createPublishRecord({ queueItemId: 'queue-delegate' }) mockGitHubClient.listIssues.mockResolvedValue([]) diff --git a/src/__tests__/unit/L3-services/postStore.test.ts b/src/__tests__/unit/L3-services/postStore.test.ts index 5747ab10..8693b9e6 100644 --- a/src/__tests__/unit/L3-services/postStore.test.ts +++ b/src/__tests__/unit/L3-services/postStore.test.ts @@ -266,12 +266,14 @@ describe('postStore', () => { platform: 'youtube', queueItemId: 'approve-ideas', lateUrl: 'https://youtube.com/watch?v=ideas', + publishedUrl: 'https://youtube.com/watch?v=ideas', })) expect(mockMarkPublished).toHaveBeenNthCalledWith(2, 2, expect.objectContaining({ clipType: 'short', platform: 'youtube', queueItemId: 'approve-ideas', lateUrl: 'https://youtube.com/watch?v=ideas', + publishedUrl: 'https://youtube.com/watch?v=ideas', })) const publishedMeta = JSON.parse( @@ -296,6 +298,7 @@ describe('postStore', () => { expect(mockMarkPublished).toHaveBeenCalledWith(1, expect.objectContaining({ latePostId: 'late-dashboard', lateUrl: 'https://app.late.co/dashboard/post/late-dashboard', + publishedUrl: undefined, })) }) diff --git a/src/__tests__/unit/L4-agents/cloudStorageOperations.test.ts b/src/__tests__/unit/L4-agents/cloudStorageOperations.test.ts index 33a058f4..320b970f 100644 --- a/src/__tests__/unit/L4-agents/cloudStorageOperations.test.ts +++ b/src/__tests__/unit/L4-agents/cloudStorageOperations.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' const mockIsAzureConfigured = vi.hoisted(() => vi.fn().mockReturnValue(true)) -const mockUploadRawVideo = vi.hoisted(() => vi.fn().mockResolvedValue('raw/123-video.mp4')) -const mockUploadPublishQueue = vi.hoisted(() => vi.fn().mockResolvedValue({ uploaded: 5, errors: [] })) +const mockUploadRawVideo = vi.hoisted(() => vi.fn().mockResolvedValue({ blobPath: 'raw/123-video.mp4', url: 'https://blob/video.mp4' })) +const mockUploadPublishQueue = vi.hoisted(() => vi.fn().mockResolvedValue({ uploaded: 5, errors: [], assets: [] })) const mockGetRunId = vi.hoisted(() => vi.fn().mockReturnValue('test-run-id')) const mockMigrateLocalContent = vi.hoisted(() => vi.fn().mockResolvedValue({ uploaded: 0, errors: [] })) const mockPushConfig = vi.hoisted(() => vi.fn().mockResolvedValue({ uploaded: 3 })) @@ -57,6 +57,8 @@ describe('L4 Unit: Cloud Storage Operations', () => { expect(result.videoUploaded).toBe(true) expect(result.contentUploaded).toBe(5) expect(result.errors).toHaveLength(0) + expect(result.videoUrl).toBe('https://blob/video.mp4') + expect(result.assets).toEqual([]) expect(mockUploadRawVideo).toHaveBeenCalled() expect(mockUploadPublishQueue).toHaveBeenCalled() }) @@ -71,6 +73,7 @@ describe('L4 Unit: Cloud Storage Operations', () => { expect(result.videoUploaded).toBe(false) expect(result.contentUploaded).toBe(5) + expect(result.assets).toEqual([]) expect(mockUploadPublishQueue).toHaveBeenCalled() }) diff --git a/src/__tests__/unit/L5-assets/cloudStorageBridge.test.ts b/src/__tests__/unit/L5-assets/cloudStorageBridge.test.ts index 3ee76f83..c19c1345 100644 --- a/src/__tests__/unit/L5-assets/cloudStorageBridge.test.ts +++ b/src/__tests__/unit/L5-assets/cloudStorageBridge.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, vi } from 'vitest' const mockIsCloudEnabled = vi.hoisted(() => vi.fn().mockReturnValue(true)) const mockUploadPipelineResults = vi.hoisted(() => vi.fn().mockResolvedValue({ - runId: 'test-run', videoUploaded: true, contentUploaded: 3, errors: [], + runId: 'test-run', videoUploaded: true, contentUploaded: 3, errors: [], videoUrl: 'https://blob/video.mp4', assets: [], })) vi.mock('../../../L4-agents/cloudStorage/cloudStorageOperations.js', () => ({ diff --git a/src/__tests__/unit/L6-pipeline/pipeline.test.ts b/src/__tests__/unit/L6-pipeline/pipeline.test.ts index 6ca2d6ee..ae8b3c02 100644 --- a/src/__tests__/unit/L6-pipeline/pipeline.test.ts +++ b/src/__tests__/unit/L6-pipeline/pipeline.test.ts @@ -40,6 +40,9 @@ const { mockGetEditorialDirection, mockGetMetadata, mockGetIntroOutroVideo, + mockIsCloudEnabled, + mockUploadToCloud, + mockRecordCloudAsset, } = vi.hoisted(() => ({ mockLogger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, mockGetConfig: vi.fn(), @@ -74,6 +77,9 @@ const { mockGetEditorialDirection: vi.fn().mockResolvedValue('editorial direction text'), mockGetMetadata: vi.fn().mockResolvedValue({ width: 1920, height: 1080, duration: 120 }), mockGetIntroOutroVideo: vi.fn().mockResolvedValue('/intro-outro.mp4'), + mockIsCloudEnabled: vi.fn().mockResolvedValue(false), + mockUploadToCloud: vi.fn().mockResolvedValue({ runId: 'run-1', videoUploaded: true, contentUploaded: 0, errors: [], assets: [] }), + mockRecordCloudAsset: vi.fn().mockResolvedValue(undefined), })) // ---- Mock L1 dependencies ---- @@ -114,6 +120,15 @@ vi.mock('../../../L5-assets/pipelineServices.js', () => ({ markFailed: mockMarkFailed, })) +vi.mock('../../../L5-assets/bridges/cloudStorageBridge.js', () => ({ + isCloudEnabled: mockIsCloudEnabled, + uploadToCloud: mockUploadToCloud, +})) + +vi.mock('../../../L3-services/ideaService/ideaService.js', () => ({ + recordCloudAsset: mockRecordCloudAsset, +})) + // Mock visual enhancement (L6-internal) to prevent eager module loading vi.mock('../../../L6-pipeline/stages/visualEnhancement.js', () => ({ enhanceVideo: vi.fn().mockResolvedValue(undefined), @@ -436,6 +451,8 @@ describe('processVideo', () => { mockGenerateMediumClipPostsData.mockResolvedValue([]) mockGetBlog.mockResolvedValue('# Blog') mockBuildQueue.mockResolvedValue(undefined) + mockIsCloudEnabled.mockResolvedValue(false) + mockUploadToCloud.mockResolvedValue({ runId: 'run-1', videoUploaded: true, contentUploaded: 0, errors: [], assets: [] }) }) it('returns a PipelineResult with all stages recorded', async () => { @@ -447,6 +464,62 @@ describe('processVideo', () => { expect(result.totalDuration).toBeGreaterThanOrEqual(0) }) + it('records uploaded cloud asset URLs back to linked ideas', async () => { + mockIsCloudEnabled.mockResolvedValue(true) + mockUploadToCloud.mockResolvedValue({ + runId: 'run-1', + videoUploaded: true, + videoUrl: 'https://blob.example/raw/test-video.mp4', + contentUploaded: 1, + errors: [], + assets: [{ + itemId: 'clip-1-youtube', + clipType: 'short', + ideaIds: ['42'], + blobBasePath: 'content/clip-1-youtube/', + cloudUrl: 'https://blob.example/content/clip-1-youtube/media.mp4', + thumbnailUrl: 'https://blob.example/content/clip-1-youtube/thumbnail.png', + clipSlug: 'clip-1', + }], + }) + mockMainVideoAssetIngest.mockResolvedValue(makeAssetMock({ + ideas: [{ + issueNumber: 7, + issueUrl: 'https://github.com/htekdev/content-management/issues/7', + repoFullName: 'htekdev/content-management', + id: 'idea-7', + topic: 'Video idea', + hook: 'Hook', + audience: 'Audience', + keyTakeaway: 'Takeaway', + talkingPoints: ['One'], + platforms: [Platform.YouTube], + status: 'recorded', + tags: ['video'], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + publishBy: '2026-02-01', + }], + })) + + await processVideo('/videos/test.mp4') + + expect(mockRecordCloudAsset).toHaveBeenNthCalledWith(1, 7, expect.objectContaining({ + assetKey: 'video:test-video', + clipType: 'video', + sourceVideoSlug: 'test-video', + cloudUrl: 'https://blob.example/raw/test-video.mp4', + })) + expect(mockRecordCloudAsset).toHaveBeenNthCalledWith(2, 42, expect.objectContaining({ + assetKey: 'queue:clip-1-youtube', + clipType: 'short', + sourceVideoSlug: 'test-video', + clipSlug: 'clip-1', + cloudUrl: 'https://blob.example/content/clip-1-youtube/media.mp4', + thumbnailUrl: 'https://blob.example/content/clip-1-youtube/thumbnail.png', + })) + }) + it('sets ideas on the asset when provided', async () => { const ideas: Idea[] = [{ issueNumber: 1,