126126 . issue-row : hover { background : color-mix ( in srgb, var ( --accent ) 5% , transparent) ; }
127127 . issue-row . chosen { background : color-mix ( in srgb, var ( --accent ) 10% , transparent) ; }
128128 . issue-row code { font-weight : 500 ; }
129- . gaps ul { margin : 4px 0 12px ; padding-left : 18px ; }
129+ . gaps { margin : 4px 0 12px ; }
130+ . gaps > p { display : flex; flex-wrap : wrap; gap : 8px ; align-items : center; justify-content : space-between; }
131+ . gap-task { padding : 6px 0 ; border-top : 1px solid var ( --border ) ; }
132+ . gap-head , . gap-dep-head , . gap-line { display : flex; flex-wrap : wrap; gap : 4px 8px ; align-items : center; min-height : 24px ; }
133+ . gap-head . dot , . gap-dep-head . dot { align-self : center; flex : none; }
134+ . gap-dep-head , . gap-line { padding-left : 16px ; }
135+ . gap-line . nested { padding-left : 32px ; }
136+ . gap-line button { padding : 0 8px ; font-size : 12px ; }
130137 . row-key . via-jira > a { text-decoration : underline dashed; text-underline-offset : 3px ; }
131138 . link-icon { width : 13px ; height : 13px ; margin-right : 2px ; vertical-align : -2px ; fill : none; stroke : currentColor; stroke-width : 2 ; stroke-linecap : round; stroke-linejoin : round; }
132139 . issue-row . summary { overflow : hidden; text-overflow : ellipsis; white-space : nowrap; }
@@ -380,10 +387,48 @@ <h2 x-text="t('release')"></h2>
380387 < p x-show ="pickable.length && !picked.length " class ="muted " x-text ="t('pickToFill') "> </ p >
381388 < p x-show ="copyFailed " class ="warn " x-text ="t('copyFailed') "> </ p >
382389 < div class ="gaps " x-show ="picked.length && gaps.length ">
383- < p class ="warn " x-text ="t('gapsTitle') "> </ p >
384- < ul >
385- < template x-for ="gap in gaps " :key ="gap "> < li x-text ="gap "> </ li > </ template >
386- </ ul >
390+ < p class ="warn "> < span x-text ="t('gapsTitle') "> </ span >
391+ < button type ="button " x-show ="neededPicks.length " @click ="pickNeeded() " x-text ="t('gapPickAll', { count: neededPicks.length }) "> </ button > </ p >
392+ < template x-for ="task in gaps " :key ="task.key ">
393+ < div class ="gap-task ">
394+ < div class ="gap-head ">
395+ < span class ="dot " :class ="'dot-' + (task.issue ? task.issue.status_category : 'new') " :title ="task.issue && task.issue.status "> </ span >
396+ < a :href ="task.url " x-text ="task.key "> </ a >
397+ < span x-text ="task.issue ? task.issue.summary : '' "> </ span >
398+ </ div >
399+ < template x-for ="(item, index) in gapRows(task) " :key ="index ">
400+ < div >
401+ < template x-if ="item.dep ">
402+ < div class ="gap-dep-head ">
403+ < span class ="muted " x-text ="relationLabel(item.dep.relation) "> </ span >
404+ < span class ="dot " :class ="'dot-' + (item.dep.issue.status_category || 'new') " :title ="item.dep.issue.status "> </ span >
405+ < a :href ="item.dep.issue.url " x-text ="item.dep.key "> </ a >
406+ < span class ="muted " x-text ="item.dep.issue.summary "> </ span >
407+ < template x-if ="!item.dep.lines.length ">
408+ < span :class ="statusClass(item.dep.issue) " x-text ="item.dep.issue.status "> </ span >
409+ </ template >
410+ </ div >
411+ </ template >
412+ < template x-if ="item.line ">
413+ < div class ="gap-line " :class ="{ nested: item.nested } ">
414+ < span :title ="item.line.project " x-text ="shortName(item.line.project) "> </ span >
415+ < span class ="cell " :class ="'cell-' + (item.line.state === 'tag' ? 'untagged' : item.line.state) " x-text ="item.line.reason "> </ span >
416+ < template x-if ="item.line.state === 'tag' ">
417+ < button type ="button " @click ="pickGap(item.line) " x-text ="t('gapPick', { tag: item.line.tag }) "> </ button >
418+ </ template >
419+ < template x-if ="item.line.state === 'untagged' ">
420+ < a :href ="serviceOf(item.line.project).untagged.create_url "
421+ x-text ="serviceOf(item.line.project).untagged.next_tag ? t('createTag', { name: serviceOf(item.line.project).untagged.next_tag }) : t('createAnyTag') "> </ a >
422+ </ template >
423+ < template x-for ="change in gapChanges(item.line) " :key ="change.url ">
424+ < a :href ="change.url " x-text ="change.label "> </ a >
425+ </ template >
426+ </ div >
427+ </ template >
428+ </ div >
429+ </ template >
430+ </ div >
431+ </ template >
387432 </ div >
388433
389434 < div class ="outputs " x-show ="picked.length ">
@@ -569,6 +614,8 @@ <h2 x-text="t('release')"></h2>
569614 taskFrom : "from {items}" ,
570615 gapsTitle : "Left behind by these picks:" ,
571616 gapNeeds : "needs {tag}" ,
617+ gapPick : "Pick {tag}" ,
618+ gapPickAll : { one : "Pick {count} needed tag" , other : "Pick {count} needed tags" } ,
572619 relationParent : "has parent" ,
573620 relationSubtask : "has subtask" ,
574621 } ,
@@ -677,6 +724,8 @@ <h2 x-text="t('release')"></h2>
677724 taskFrom : "из {items}" ,
678725 gapsTitle : "Не войдёт в релиз с выбранными тегами:" ,
679726 gapNeeds : "нужен тег {tag}" ,
727+ gapPick : "Выбрать {tag}" ,
728+ gapPickAll : { one : "Выбрать {count} нужный тег" , few : "Выбрать {count} нужных тега" , many : "Выбрать {count} нужных тегов" } ,
680729 relationParent : "входит в" ,
681730 relationSubtask : "включает подзадачу" ,
682731 } ,
@@ -760,6 +809,23 @@ <h2 x-text="t('release')"></h2>
760809 return { state : "elsewhere" } ;
761810 } ;
762811
812+ const namesOf = ( report ) => {
813+ const issues = report . jira ? Object . values ( report . jira . issues ) : [ ] ;
814+ const projects = new Set ( [
815+ ...report . services . map ( ( service ) => service . project ) ,
816+ ...issues . flatMap ( ( issue ) => issue . links . map ( ( link ) => link . project ) ) ,
817+ ] ) ;
818+ const paths = [ ...projects ] . map ( ( project ) => project . split ( "/" ) ) ;
819+ const suffix = ( parts , size ) => parts . slice ( - size ) . join ( "/" ) ;
820+ return Object . fromEntries (
821+ paths . map ( ( parts ) => {
822+ let size = 1 ;
823+ while ( size < parts . length && paths . some ( ( other ) => other !== parts && suffix ( other , size ) === suffix ( parts , size ) ) ) size ++ ;
824+ return [ parts . join ( "/" ) , suffix ( parts , size ) ] ;
825+ } ) ,
826+ ) ;
827+ } ;
828+
763829 const reachOf = ( report ) => {
764830 const issues = report . jira ? report . jira . issues : { } ;
765831 const reach = Object . fromEntries ( Object . keys ( issues ) . map ( ( key ) => [ key , [ ] ] ) ) ;
@@ -814,6 +880,7 @@ <h2 x-text="t('release')"></h2>
814880 lang : "en" ,
815881 theme : "auto" ,
816882 reach : { } ,
883+ names : { } ,
817884 chosen : [ ] ,
818885 autoPicks : { } ,
819886 issueFilter : "" ,
@@ -833,6 +900,7 @@ <h2 x-text="t('release')"></h2>
833900 this . picks [ service . project ] = service . candidates . some ( ( item ) => item . tag . name === tag ) ? tag : "" ;
834901 }
835902 this . reach = reachOf ( report ) ;
903+ this . names = namesOf ( report ) ;
836904 this . report = report ;
837905 this . readHash ( ) ;
838906 window . addEventListener ( "hashchange" , ( ) => this . readHash ( ) ) ;
@@ -883,17 +951,11 @@ <h2 x-text="t('release')"></h2>
883951 get listed ( ) {
884952 return this . report . services . filter ( ( service ) => service . error || this . production ( service ) || service . rows . length ) ;
885953 } ,
886- get sharedPrefix ( ) {
887- const paths = this . listed . map ( ( service ) => service . project . split ( "/" ) ) ;
888- if ( paths . length < 2 ) return "" ;
889- const shared = paths . reduce ( ( prefix , parts ) => {
890- const end = prefix . findIndex ( ( part , index ) => part !== parts [ index ] || index >= parts . length - 1 ) ;
891- return end === - 1 ? prefix : prefix . slice ( 0 , end ) ;
892- } ) ;
893- return shared . length ? shared . join ( "/" ) + "/" : "" ;
894- } ,
895954 shortName ( project ) {
896- return this . sharedPrefix && project . startsWith ( this . sharedPrefix ) ? project . slice ( this . sharedPrefix . length ) : project ;
955+ return this . names [ project ] || project ;
956+ } ,
957+ serviceOf ( project ) {
958+ return this . report . services . find ( ( service ) => service . project === project ) ;
897959 } ,
898960 get pickable ( ) {
899961 return this . listed . filter ( ( service ) => service . candidates . length ) ;
@@ -927,22 +989,46 @@ <h2 x-text="t('release')"></h2>
927989 } ,
928990 get gaps ( ) {
929991 const starts = new Map ( this . picked . map ( ( { service, candidate } ) => [ service . project , this . tagRow ( service , candidate . tag . name ) ] ) ) ;
930- const gaps = new Set ( ) ;
931- const check = ( source , relation , key ) => {
932- const via = relation ? ` ${ this . relationLabel ( relation ) } ${ key } ` : "" ;
933- const reach = this . reach [ key ] || [ ] ;
934- for ( const item of reach ) {
935- const reason = this . gapReason ( item , starts ) ;
936- if ( reason ) gaps . add ( `${ source } ${ via } → ${ this . shortName ( item . project ) } : ${ reason } ` ) ;
992+ const lines = ( key ) =>
993+ ( this . reach [ key ] || [ ] ) . map ( ( item ) => ( { ...item , key, reason : this . gapReason ( item , starts ) } ) ) . filter ( ( item ) => item . reason ) ;
994+ return this . tasks
995+ . map ( ( task ) => ( {
996+ ...task ,
997+ lines : lines ( task . key ) ,
998+ deps : ( task . issue ? task . issue . relations . filter ( dependsOn ) : [ ] )
999+ . map ( ( relation ) => ( { relation, key : relation . key , issue : this . issue ( relation . key ) , lines : lines ( relation . key ) } ) )
1000+ . filter ( ( dep ) =>
1001+ dep . lines . length || ( dep . issue && ! ( this . reach [ dep . key ] || [ ] ) . length && dep . issue . status_category !== "done" ) ) ,
1002+ } ) )
1003+ . filter ( ( task ) => task . lines . length || task . deps . length ) ;
1004+ } ,
1005+ gapRows ( task ) {
1006+ return [
1007+ ...task . lines . map ( ( line ) => ( { line } ) ) ,
1008+ ...task . deps . flatMap ( ( dep ) => [ { dep } , ...dep . lines . map ( ( line ) => ( { line, nested : true } ) ) ] ) ,
1009+ ] ;
1010+ } ,
1011+ gapChanges ( line ) {
1012+ if ( ! [ "open" , "branch" , "elsewhere" ] . includes ( line . state ) ) return [ ] ;
1013+ return this . issue ( line . key ) . links
1014+ . filter ( ( link ) => link . project === line . project )
1015+ . map ( ( link ) => ( { url : link . url , label : link . iid != null ? "!" + link . iid : link . sha . slice ( 0 , 8 ) } ) ) ;
1016+ } ,
1017+ get neededPicks ( ) {
1018+ const needed = new Map ( ) ;
1019+ for ( const task of this . gaps ) {
1020+ for ( const line of [ ...task . lines , ...task . deps . flatMap ( ( dep ) => dep . lines ) ] ) {
1021+ const known = needed . get ( line . project ) ;
1022+ if ( line . state === "tag" && ( ! known || line . tagIndex < known . tagIndex ) ) needed . set ( line . project , line ) ;
9371023 }
938- const issue = this . issue ( key ) ;
939- if ( relation && ! reach . length && issue && issue . status_category !== "done" ) gaps . add ( `${ source } ${ via } : ${ issue . status } ` ) ;
940- } ;
941- for ( const task of this . tasks ) {
942- check ( task . key , null , task . key ) ;
943- for ( const relation of task . issue ? task . issue . relations . filter ( dependsOn ) : [ ] ) check ( task . key , relation , relation . key ) ;
9441024 }
945- return [ ...gaps ] ;
1025+ return [ ...needed . values ( ) ] ;
1026+ } ,
1027+ pickGap ( line ) {
1028+ this . pick ( this . serviceOf ( line . project ) , line . tag ) ;
1029+ } ,
1030+ pickNeeded ( ) {
1031+ for ( const line of this . neededPicks ) this . pickGap ( line ) ;
9461032 } ,
9471033 gapReason ( item , starts ) {
9481034 if ( item . state === "tag" || item . state === "untagged" ) {
@@ -1076,7 +1162,7 @@ <h2 x-text="t('release')"></h2>
10761162 return entry . best || entry . untagged . length ? items : this . t ( "planElsewhere" , { items } ) ;
10771163 } ,
10781164 reachText ( item ) {
1079- return `${ item . project . split ( "/" ) . pop ( ) } ${ this . reachLabel ( item ) } ` ;
1165+ return `${ this . shortName ( item . project ) } ${ this . reachLabel ( item ) } ` ;
10801166 } ,
10811167 showIssues ( view ) {
10821168 this . issueView = view ;
@@ -1102,7 +1188,7 @@ <h2 x-text="t('release')"></h2>
11021188 pick ( service , tag , element ) {
11031189 this . picks [ service . project ] = tag ;
11041190 this . untickMissed ( service ) ;
1105- element . closest ( "details" ) . open = false ;
1191+ if ( element ) element . closest ( "details" ) . open = false ;
11061192 this . writeHash ( ) ;
11071193 } ,
11081194 untickMissed ( service ) {
0 commit comments