11import { Octokit } from '@octokit/rest' ;
22import * as core from '@actions/core' ;
3- import { IssueLabeling as _IssueLabeling } from './issue-labeling.js' ;
3+ import { context } from '@actions/github' ;
4+ import {
5+ IssueLabeling as _IssueLabeling ,
6+ NEEDS_TRIAGE_MILESTONE ,
7+ BACKLOG_MILESTONE ,
8+ } from './issue-labeling.js' ;
49
510class IssueLabeling extends _IssueLabeling {
611 setGit ( git : any ) {
@@ -15,8 +20,23 @@ describe('IssueLabeling', () => {
1520 let getIssue : jasmine . Spy ;
1621
1722 beforeEach ( ( ) => {
23+ // Set up GitHub Action context defaults for tests
24+ context . payload = { action : 'opened' } ;
25+ context . eventName = 'issues' ;
26+ spyOnProperty ( context , 'issue' , 'get' ) . and . returnValue ( {
27+ owner : 'angular' ,
28+ repo : 'dev-infra' ,
29+ number : 123 ,
30+ } ) ;
31+
1832 mockGit = jasmine . createSpyObj ( 'Octokit' , [ 'paginate' , 'issues' , 'pulls' ] ) ;
19- mockGit . issues = jasmine . createSpyObj ( 'issues' , [ 'addLabels' , 'get' , 'listLabelsForRepo' ] ) ;
33+ mockGit . issues = jasmine . createSpyObj ( 'issues' , [
34+ 'addLabels' ,
35+ 'get' ,
36+ 'listLabelsForRepo' ,
37+ 'listMilestones' ,
38+ 'update' ,
39+ ] ) ;
2040
2141 // Mock paginate to return the result of the promise if it's a list, or just execute the callback
2242 ( mockGit . paginate as jasmine . Spy ) . and . callFake ( ( fn : any , args : any ) => {
@@ -27,10 +47,17 @@ describe('IssueLabeling', () => {
2747 { name : 'bug' , description : 'Bug report' } ,
2848 ] ) ;
2949 }
50+ if ( fn === mockGit . issues . listMilestones ) {
51+ return Promise . resolve ( [
52+ { number : 1 , title : NEEDS_TRIAGE_MILESTONE } ,
53+ { number : 2 , title : BACKLOG_MILESTONE } ,
54+ ] ) ;
55+ }
3056 return Promise . resolve ( [ ] ) ;
3157 } ) ;
3258
3359 ( mockGit . issues . addLabels as unknown as jasmine . Spy ) . and . returnValue ( Promise . resolve ( { } ) ) ;
60+ ( mockGit . issues . update as unknown as jasmine . Spy ) . and . returnValue ( Promise . resolve ( { } ) ) ;
3461 getIssue = mockGit . issues . get as unknown as jasmine . Spy ;
3562 getIssue . and . resolveTo ( {
3663 data : {
@@ -44,6 +71,9 @@ describe('IssueLabeling', () => {
4471 models : jasmine . createSpyObj ( 'models' , [ 'generateContent' ] ) ,
4572 } ;
4673
74+ // By default, mock AI returns a safe non-matching value so it doesn't crash un-stubbed tests.
75+ mockAI . models . generateContent . and . returnValue ( Promise . resolve ( { text : 'none' } ) ) ;
76+
4777 spyOn ( IssueLabeling . prototype , 'getGenerativeAI' ) . and . returnValue ( mockAI ) ;
4878 issueLabeling = new IssueLabeling ( ) ;
4979 issueLabeling . setGit ( mockGit as unknown as Octokit ) ;
@@ -56,21 +86,43 @@ describe('IssueLabeling', () => {
5686 expect ( issueLabeling . repoAreaLabels . has ( 'bug' ) ) . toBe ( false ) ;
5787 } ) ;
5888
59- it ( 'should apply a label when Gemini is confident' , async ( ) => {
89+ it ( 'should apply a label and milestone when Gemini is confident on opened ' , async ( ) => {
6090 mockAI . models . generateContent . and . returnValue (
6191 Promise . resolve ( {
6292 text : 'area: core' ,
6393 } ) ,
6494 ) ;
6595
66- await issueLabeling . initialize ( ) ;
96+ let getCallCount = 0 ;
97+ getIssue . and . callFake ( ( ) => {
98+ getCallCount ++ ;
99+ if ( getCallCount === 1 ) {
100+ return Promise . resolve ( {
101+ data : { title : 'Tough Issue' , body : 'Complex Body' , labels : [ ] } ,
102+ } ) ;
103+ }
104+ return Promise . resolve ( {
105+ data : {
106+ title : 'Tough Issue' ,
107+ body : 'Complex Body' ,
108+ labels : [ 'area: core' ] ,
109+ } ,
110+ } ) ;
111+ } ) ;
112+
67113 await issueLabeling . run ( ) ;
68114
69115 expect ( mockGit . issues . addLabels ) . toHaveBeenCalledWith (
70116 jasmine . objectContaining ( {
71117 labels : [ 'area: core' ] ,
72118 } ) ,
73119 ) ;
120+ expect ( mockGit . issues . update ) . toHaveBeenCalledWith (
121+ jasmine . objectContaining ( {
122+ issue_number : 123 ,
123+ milestone : 1 ,
124+ } ) ,
125+ ) ;
74126 } ) ;
75127
76128 it ( 'should NOT apply a label when Gemini returns "ambiguous"' , async ( ) => {
@@ -80,7 +132,6 @@ describe('IssueLabeling', () => {
80132 } ) ,
81133 ) ;
82134
83- await issueLabeling . initialize ( ) ;
84135 await issueLabeling . run ( ) ;
85136
86137 expect ( mockGit . issues . addLabels ) . not . toHaveBeenCalled ( ) ;
@@ -93,24 +144,85 @@ describe('IssueLabeling', () => {
93144 } ) ,
94145 ) ;
95146
96- await issueLabeling . initialize ( ) ;
97147 await issueLabeling . run ( ) ;
98148
99149 expect ( mockGit . issues . addLabels ) . not . toHaveBeenCalled ( ) ;
100150 } ) ;
101151
102- it ( 'should skip labeling when issue already has an area label' , async ( ) => {
152+ it ( 'should apply needsTriage milestone when an area label is added manually' , async ( ) => {
153+ context . payload = { action : 'labeled' , label : { name : 'area: core' } } ;
103154 getIssue . and . resolveTo ( {
104155 data : {
105156 title : 'Tough Issue' ,
106157 body : 'Complex Body' ,
107- labels : [ { name : 'area: core' } ] ,
158+ labels : [ 'area: core' ] ,
108159 } ,
109160 } ) ;
110- await issueLabeling . initialize ( ) ;
111161
112162 await issueLabeling . run ( ) ;
113163
114- expect ( mockGit . issues . addLabels ) . not . toHaveBeenCalled ( ) ;
164+ expect ( mockGit . issues . update ) . toHaveBeenCalledWith (
165+ jasmine . objectContaining ( {
166+ issue_number : 123 ,
167+ milestone : 1 ,
168+ } ) ,
169+ ) ;
170+ } ) ;
171+
172+ it ( 'should apply Backlog milestone when a priority label is added manually' , async ( ) => {
173+ context . payload = { action : 'labeled' , label : { name : 'P0' } } ;
174+ getIssue . and . resolveTo ( {
175+ data : {
176+ title : 'Tough Issue' ,
177+ body : 'Complex Body' ,
178+ labels : [ 'P0' ] ,
179+ } ,
180+ } ) ;
181+
182+ await issueLabeling . run ( ) ;
183+
184+ expect ( mockGit . issues . update ) . toHaveBeenCalledWith (
185+ jasmine . objectContaining ( {
186+ issue_number : 123 ,
187+ milestone : 2 ,
188+ } ) ,
189+ ) ;
190+ } ) ;
191+
192+ it ( 'should NOT overwrite an existing milestone when applying a new one' , async ( ) => {
193+ context . payload = { action : 'labeled' , label : { name : 'P1' } } ;
194+ getIssue . and . resolveTo ( {
195+ data : {
196+ title : 'Tough Issue' ,
197+ body : 'Complex Body' ,
198+ labels : [ 'P1' ] ,
199+ milestone : { title : 'Release 20' , number : 99 } ,
200+ } ,
201+ } ) ;
202+
203+ await issueLabeling . run ( ) ;
204+
205+ expect ( mockGit . issues . update ) . not . toHaveBeenCalled ( ) ;
206+ } ) ;
207+
208+ it ( 'should transition milestone from needsTriage to Backlog' , async ( ) => {
209+ context . payload = { action : 'labeled' , label : { name : 'P2' } } ;
210+ getIssue . and . resolveTo ( {
211+ data : {
212+ title : 'Tough Issue' ,
213+ body : 'Complex Body' ,
214+ labels : [ 'area: core' , 'P2' ] ,
215+ milestone : { title : NEEDS_TRIAGE_MILESTONE , number : 1 } ,
216+ } ,
217+ } ) ;
218+
219+ await issueLabeling . run ( ) ;
220+
221+ expect ( mockGit . issues . update ) . toHaveBeenCalledWith (
222+ jasmine . objectContaining ( {
223+ issue_number : 123 ,
224+ milestone : 2 ,
225+ } ) ,
226+ ) ;
115227 } ) ;
116228} ) ;
0 commit comments