@@ -24,6 +24,8 @@ const tagSha = requiredEnv("TAG_SHA");
2424const issueIdsPath = requiredEnv ( "ISSUE_IDS_PATH" ) ;
2525const featureOsUrlsPath = env . FEATUREOS_URLS_PATH ;
2626const githubPrUrlsPath = env . GITHUB_PR_URLS_PATH ;
27+ const prSummaryPath = env . PR_SUMMARY_PATH ;
28+ const logPath = env . LOG_PATH ;
2729
2830const pipelineName = RELEASE_PIPELINE_BY_CHANNEL [ releaseChannel ] ;
2931if ( ! pipelineName ) {
@@ -38,6 +40,7 @@ const githubPrUrls = githubPrUrlsPath ? readLines(githubPrUrlsPath) : [];
3840const pipeline = await findReleasePipeline ( pipelineName ) ;
3941const targetStage = findStage ( pipeline , targetStageName ) ;
4042const release = await upsertRelease ( { pipeline, targetStage } ) ;
43+ await syncWebguiReleaseNotes ( pipeline , release ) ;
4144const relatedReleases = await resolveRelatedReleases ( pipeline ) ;
4245const syncResult = await syncIssuesToRelease ( release , relatedReleases , { issueIdentifiers, featureOsUrls, githubPrUrls } ) ;
4346
@@ -304,6 +307,13 @@ async function findRelease(pipelineId, version, name) {
304307 name
305308 type
306309 }
310+ releaseNotes {
311+ id
312+ title
313+ documentContent {
314+ content
315+ }
316+ }
307317 }
308318 }
309319 }
@@ -331,6 +341,13 @@ async function createRelease(input) {
331341 name
332342 type
333343 }
344+ releaseNotes {
345+ id
346+ title
347+ documentContent {
348+ content
349+ }
350+ }
334351 }
335352 }
336353 }
@@ -360,6 +377,13 @@ async function updateRelease(id, input) {
360377 name
361378 type
362379 }
380+ releaseNotes {
381+ id
382+ title
383+ documentContent {
384+ content
385+ }
386+ }
363387 }
364388 }
365389 }
@@ -372,6 +396,124 @@ async function updateRelease(id, input) {
372396 return data . releaseUpdate . release ;
373397}
374398
399+ async function syncWebguiReleaseNotes ( pipeline , release ) {
400+ const content = buildWebguiReleaseNotes ( ) ;
401+ if ( ! content || ! release ?. id ) {
402+ return ;
403+ }
404+
405+ const title = `Version ${ tagName } ` ;
406+ const existingNote = findReleaseNote ( release , title ) ;
407+ const nextContent = renderManagedSection (
408+ existingNote ?. documentContent ?. content || "" ,
409+ "notification-worker-webgui-release-notes" ,
410+ title ,
411+ content ,
412+ ) ;
413+
414+ if ( existingNote ?. id ) {
415+ if ( nextContent !== ( existingNote . documentContent ?. content || "" ) || existingNote . title !== title ) {
416+ await updateReleaseNote ( existingNote . id , {
417+ releaseId : release . id ,
418+ title,
419+ content : nextContent ,
420+ } ) ;
421+ }
422+ return ;
423+ }
424+
425+ await createReleaseNote ( {
426+ pipelineId : pipeline . id ,
427+ releaseId : release . id ,
428+ title,
429+ content : nextContent ,
430+ } ) ;
431+ }
432+
433+ function buildWebguiReleaseNotes ( ) {
434+ const prSummaries = prSummaryPath ? readOptionalLines ( prSummaryPath ) : [ ] ;
435+ const commitSubjects = logPath ? readCommitSubjects ( logPath ) : [ ] ;
436+ const metadata = [
437+ `Tag: \`${ tagName } \`` ,
438+ `Commit: \`${ tagSha } \`` ,
439+ env . PREVIOUS_TAG ? `Previous tag: \`${ env . PREVIOUS_TAG } \`` : undefined ,
440+ env . RANGE_SPEC ? `Commit range: \`${ env . RANGE_SPEC } \`` : undefined ,
441+ ] . filter ( Boolean ) ;
442+ const sections = [ [ "## Release Metadata" , ...metadata ] ] ;
443+
444+ if ( prSummaries . length > 0 ) {
445+ sections . push ( [ "## WebGUI Pull Requests" , ...prSummaries ] ) ;
446+ }
447+ if ( issueIdentifiers . length > 0 ) {
448+ sections . push ( [ "## Linked Linear Issues" , ...issueIdentifiers . map ( ( id ) => `- ${ id } ` ) ] ) ;
449+ }
450+ if ( featureOsUrls . length > 0 ) {
451+ sections . push ( [ "## Linked FeatureOS Posts" , ...featureOsUrls . map ( ( url ) => `- ${ url } ` ) ] ) ;
452+ }
453+ if ( prSummaries . length === 0 && commitSubjects . length > 0 ) {
454+ sections . push ( [ "## Commit Summary" , ...commitSubjects . slice ( 0 , 25 ) . map ( ( subject ) => `- ${ subject } ` ) ] ) ;
455+ }
456+
457+ return sections . map ( ( section ) => section . join ( "\n" ) ) . join ( "\n\n" ) . trim ( ) ;
458+ }
459+
460+ function findReleaseNote ( release , title ) {
461+ const normalizedTitle = title . trim ( ) . toLowerCase ( ) ;
462+ const marker = managedSectionStartMarker ( "notification-worker-webgui-release-notes" , title ) ;
463+ return ( release . releaseNotes || [ ] ) . find ( ( note ) => ( note . title || "" ) . trim ( ) . toLowerCase ( ) === normalizedTitle )
464+ || ( release . releaseNotes || [ ] ) . find ( ( note ) => ( note . documentContent ?. content || "" ) . includes ( marker ) ) ;
465+ }
466+
467+ async function createReleaseNote ( input ) {
468+ const data = await graphql ( `
469+ mutation CreateReleaseNote($input: ReleaseNoteCreateInput!) {
470+ releaseNoteCreate(input: $input) {
471+ success
472+ releaseNote {
473+ id
474+ title
475+ }
476+ }
477+ }
478+ ` , {
479+ input : dropUndefined ( {
480+ pipelineId : input . pipelineId ,
481+ releaseIds : [ input . releaseId ] ,
482+ title : input . title ,
483+ content : input . content ,
484+ } ) ,
485+ } ) ;
486+
487+ if ( ! data . releaseNoteCreate . success ) {
488+ throw new Error ( `Linear release note create failed for ${ input . releaseId } ` ) ;
489+ }
490+ }
491+
492+ async function updateReleaseNote ( id , input ) {
493+ const data = await graphql ( `
494+ mutation UpdateReleaseNote($id: String!, $input: ReleaseNoteUpdateInput!) {
495+ releaseNoteUpdate(id: $id, input: $input) {
496+ success
497+ releaseNote {
498+ id
499+ title
500+ }
501+ }
502+ }
503+ ` , {
504+ id,
505+ input : dropUndefined ( {
506+ releaseIds : [ input . releaseId ] ,
507+ title : input . title ,
508+ content : input . content ,
509+ } ) ,
510+ } ) ;
511+
512+ if ( ! data . releaseNoteUpdate . success ) {
513+ throw new Error ( `Linear release note update failed for ${ id } ` ) ;
514+ }
515+ }
516+
375517async function findIssue ( identifier ) {
376518 const data = await graphql ( `
377519 query FindIssue($id: String!) {
@@ -548,6 +690,67 @@ function readLines(path) {
548690 . filter ( ( value , index , values ) => values . indexOf ( value ) === index ) ;
549691}
550692
693+ function readOptionalLines ( path ) {
694+ try {
695+ return readLines ( path ) ;
696+ } catch {
697+ return [ ] ;
698+ }
699+ }
700+
701+ function readCommitSubjects ( path ) {
702+ try {
703+ return readFileSync ( path , "utf8" )
704+ . split ( / \r ? \n / )
705+ . map ( ( line ) => line . trim ( ) )
706+ . filter ( ( line ) => line && ! line . startsWith ( "Merge pull request #" ) )
707+ . filter ( ( value , index , values ) => values . indexOf ( value ) === index ) ;
708+ } catch {
709+ return [ ] ;
710+ }
711+ }
712+
713+ function renderManagedSection ( content , markerPrefix , title , body ) {
714+ const normalizedTitle = title . trim ( ) || "Release Notes" ;
715+ const normalizedBody = body . trim ( ) ;
716+ if ( ! normalizedBody ) {
717+ return content ;
718+ }
719+
720+ const startMarker = managedSectionStartMarker ( markerPrefix , normalizedTitle ) ;
721+ const endMarker = `<!-- ${ markerPrefix } :end:${ stableMarkerHash ( normalizedTitle ) } -->` ;
722+ const section = [
723+ startMarker ,
724+ `# ${ normalizedTitle } ` ,
725+ "" ,
726+ normalizedBody ,
727+ endMarker ,
728+ ] . join ( "\n" ) . trim ( ) ;
729+ const existing = content . trim ( ) ;
730+ const pattern = new RegExp ( `${ escapeRegExp ( startMarker ) } [\\s\\S]*?${ escapeRegExp ( endMarker ) } ` , "m" ) ;
731+ if ( pattern . test ( existing ) ) {
732+ return existing . replace ( pattern , section ) . trim ( ) ;
733+ }
734+
735+ return [ existing , section ] . filter ( Boolean ) . join ( "\n\n" ) . trim ( ) ;
736+ }
737+
738+ function managedSectionStartMarker ( markerPrefix , title ) {
739+ return `<!-- ${ markerPrefix } :start:${ stableMarkerHash ( title . trim ( ) || "Release Notes" ) } -->` ;
740+ }
741+
742+ function stableMarkerHash ( value ) {
743+ let hash = 5381 ;
744+ for ( let index = 0 ; index < value . length ; index += 1 ) {
745+ hash = ( ( hash << 5 ) + hash ) ^ value . charCodeAt ( index ) ;
746+ }
747+ return ( hash >>> 0 ) . toString ( 16 ) ;
748+ }
749+
750+ function escapeRegExp ( value ) {
751+ return value . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
752+ }
753+
551754function candidateAttachmentUrls ( url ) {
552755 const candidates = new Set ( [ url ] ) ;
553756 try {
0 commit comments