@@ -2114,3 +2114,206 @@ describe("create_pull_request - threat detection caution", () => {
21142114 expect ( ( between . match ( / \n / g) || [ ] ) . length ) . toBeGreaterThanOrEqual ( 2 ) ;
21152115 } ) ;
21162116} ) ;
2117+
2118+ describe ( "create_pull_request - rate-limit retry" , ( ) => {
2119+ let originalEnv ;
2120+ let tempDir ;
2121+
2122+ /**
2123+ * Creates a mock GitHub API rate-limit error object (HTTP 403 with x-ratelimit-remaining: 0)
2124+ * that matches what octokit returns when the installation token quota is exhausted.
2125+ * @param {string } [message]
2126+ * @returns {Error }
2127+ */
2128+ function createRateLimitError ( message = "API rate limit exceeded" ) {
2129+ return Object . assign ( new Error ( message ) , {
2130+ status : 403 ,
2131+ response : { headers : { "x-ratelimit-remaining" : "0" } , status : 403 } ,
2132+ } ) ;
2133+ }
2134+
2135+ beforeEach ( ( ) => {
2136+ originalEnv = { ...process . env } ;
2137+ process . env . GH_AW_WORKFLOW_ID = "test-workflow" ;
2138+ process . env . GITHUB_REPOSITORY = "test-owner/test-repo" ;
2139+ process . env . GITHUB_BASE_REF = "main" ;
2140+ tempDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "create-pr-rate-limit-test-" ) ) ;
2141+
2142+ global . core = {
2143+ info : vi . fn ( ) ,
2144+ warning : vi . fn ( ) ,
2145+ error : vi . fn ( ) ,
2146+ debug : vi . fn ( ) ,
2147+ setFailed : vi . fn ( ) ,
2148+ setOutput : vi . fn ( ) ,
2149+ startGroup : vi . fn ( ) ,
2150+ endGroup : vi . fn ( ) ,
2151+ summary : {
2152+ addRaw : vi . fn ( ) . mockReturnThis ( ) ,
2153+ write : vi . fn ( ) . mockResolvedValue ( undefined ) ,
2154+ } ,
2155+ } ;
2156+
2157+ global . github = {
2158+ rest : {
2159+ pulls : {
2160+ create : vi . fn ( ) . mockResolvedValue ( { data : { number : 42 , html_url : "https://github.com/test/pull/42" } } ) ,
2161+ requestReviewers : vi . fn ( ) . mockResolvedValue ( { } ) ,
2162+ } ,
2163+ repos : {
2164+ get : vi . fn ( ) . mockResolvedValue ( { data : { default_branch : "main" } } ) ,
2165+ } ,
2166+ issues : {
2167+ create : vi . fn ( ) . mockResolvedValue ( { data : { number : 99 , html_url : "https://github.com/test/issues/99" } } ) ,
2168+ addLabels : vi . fn ( ) . mockResolvedValue ( { } ) ,
2169+ } ,
2170+ } ,
2171+ graphql : vi . fn ( ) ,
2172+ } ;
2173+
2174+ global . context = {
2175+ eventName : "issues" ,
2176+ repo : { owner : "test-owner" , repo : "test-repo" } ,
2177+ payload : { } ,
2178+ runId : "12345" ,
2179+ } ;
2180+
2181+ global . exec = {
2182+ exec : vi . fn ( ) . mockResolvedValue ( 0 ) ,
2183+ getExecOutput : vi . fn ( ) . mockImplementation ( async ( program , args ) => {
2184+ if ( program === "git" && args [ 0 ] === "rev-list" ) {
2185+ return { exitCode : 0 , stdout : "1" , stderr : "" } ;
2186+ }
2187+ return { exitCode : 0 , stdout : "main" , stderr : "" } ;
2188+ } ) ,
2189+ } ;
2190+
2191+ delete require . cache [ require . resolve ( "./create_pull_request.cjs" ) ] ;
2192+ } ) ;
2193+
2194+ afterEach ( ( ) => {
2195+ for ( const key of Object . keys ( process . env ) ) {
2196+ if ( ! ( key in originalEnv ) ) {
2197+ delete process . env [ key ] ;
2198+ }
2199+ }
2200+ Object . assign ( process . env , originalEnv ) ;
2201+
2202+ if ( tempDir && fs . existsSync ( tempDir ) ) {
2203+ fs . rmSync ( tempDir , { recursive : true , force : true } ) ;
2204+ }
2205+
2206+ delete global . core ;
2207+ delete global . github ;
2208+ delete global . context ;
2209+ delete global . exec ;
2210+ vi . clearAllMocks ( ) ;
2211+ } ) ;
2212+
2213+ it ( "should retry PR creation on rate limit error and succeed" , async ( ) => {
2214+ vi . useFakeTimers ( ) ;
2215+ try {
2216+ global . github . rest . pulls . create . mockRejectedValueOnce ( createRateLimitError ( ) ) . mockResolvedValue ( { data : { number : 42 , html_url : "https://github.com/test/pull/42" } } ) ;
2217+
2218+ const { main } = require ( "./create_pull_request.cjs" ) ;
2219+ const handler = await main ( { allow_empty : true } ) ;
2220+
2221+ const resultPromise = handler ( { title : "Test PR" , body : "Test body" } , { } ) ;
2222+
2223+ await vi . runAllTimersAsync ( ) ;
2224+
2225+ const result = await resultPromise ;
2226+
2227+ expect ( result . success ) . toBe ( true ) ;
2228+ expect ( result . pull_request_number ) . toBe ( 42 ) ;
2229+ // 1 initial (rate-limited) + 1 retry (succeeds) = 2 calls total
2230+ expect ( global . github . rest . pulls . create ) . toHaveBeenCalledTimes ( 2 ) ;
2231+ expect ( global . core . warning ) . toHaveBeenCalledWith ( expect . stringContaining ( "create pull request" ) ) ;
2232+ } finally {
2233+ vi . useRealTimers ( ) ;
2234+ }
2235+ } ) ;
2236+
2237+ it ( "should fall back to issue when PR creation fails after all rate-limit retries" , async ( ) => {
2238+ vi . useFakeTimers ( ) ;
2239+ try {
2240+ global . github . rest . pulls . create . mockRejectedValue ( createRateLimitError ( ) ) ;
2241+ global . github . rest . issues . create . mockResolvedValue ( { data : { number : 99 , html_url : "https://github.com/test/issues/99" } } ) ;
2242+
2243+ const { main } = require ( "./create_pull_request.cjs" ) ;
2244+ const handler = await main ( { allow_empty : true } ) ;
2245+
2246+ const resultPromise = handler ( { title : "Test PR" , body : "Test body" } , { } ) ;
2247+
2248+ await vi . runAllTimersAsync ( ) ;
2249+
2250+ const result = await resultPromise ;
2251+
2252+ // Should fall back to issue creation after PR retries are exhausted
2253+ expect ( result . success ) . toBe ( true ) ;
2254+ expect ( result . fallback_used ) . toBe ( true ) ;
2255+ expect ( result . issue_number ) . toBe ( 99 ) ;
2256+ // 1 initial + 5 retries = 6 total PR creation attempts (RATE_LIMIT_RETRY_CONFIG.maxRetries = 5)
2257+ expect ( global . github . rest . pulls . create ) . toHaveBeenCalledTimes ( 6 ) ;
2258+ expect ( global . github . rest . issues . create ) . toHaveBeenCalled ( ) ;
2259+ } finally {
2260+ vi . useRealTimers ( ) ;
2261+ }
2262+ } ) ;
2263+
2264+ it ( "should retry fallback issue creation on rate limit error and succeed" , async ( ) => {
2265+ vi . useFakeTimers ( ) ;
2266+ try {
2267+ // PR creation fails with a non-rate-limit error to trigger fallback immediately
2268+ global . github . rest . pulls . create . mockRejectedValue ( new Error ( "Some PR creation error" ) ) ;
2269+ // Fallback issue creation first fails with rate limit, then succeeds
2270+ global . github . rest . issues . create . mockRejectedValueOnce ( createRateLimitError ( ) ) . mockResolvedValue ( { data : { number : 99 , html_url : "https://github.com/test/issues/99" } } ) ;
2271+
2272+ const { main } = require ( "./create_pull_request.cjs" ) ;
2273+ const handler = await main ( { allow_empty : true } ) ;
2274+
2275+ const resultPromise = handler ( { title : "Test PR" , body : "Test body" } , { } ) ;
2276+
2277+ await vi . runAllTimersAsync ( ) ;
2278+
2279+ const result = await resultPromise ;
2280+
2281+ expect ( result . success ) . toBe ( true ) ;
2282+ expect ( result . fallback_used ) . toBe ( true ) ;
2283+ expect ( result . issue_number ) . toBe ( 99 ) ;
2284+ // Fallback issue: 1 rate-limited attempt + 1 successful retry = 2 calls
2285+ expect ( global . github . rest . issues . create ) . toHaveBeenCalledTimes ( 2 ) ;
2286+ expect ( global . core . warning ) . toHaveBeenCalledWith ( expect . stringContaining ( "create fallback issue" ) ) ;
2287+ } finally {
2288+ vi . useRealTimers ( ) ;
2289+ }
2290+ } ) ;
2291+
2292+ it ( "should append a note to the fallback issue body when assignees are removed due to 422 error" , async ( ) => {
2293+ // PR creation fails with a non-rate-limit error to trigger fallback immediately
2294+ global . github . rest . pulls . create . mockRejectedValue ( new Error ( "Some PR creation error" ) ) ;
2295+
2296+ const assigneeError = Object . assign ( new Error ( "Validation Failed: assignees are invalid" ) , {
2297+ status : 422 ,
2298+ response : { status : 422 } ,
2299+ } ) ;
2300+ // First call fails with assignee 422, second succeeds
2301+ global . github . rest . issues . create . mockRejectedValueOnce ( assigneeError ) . mockResolvedValue ( { data : { number : 77 , html_url : "https://github.com/test/issues/77" } } ) ;
2302+
2303+ const { main } = require ( "./create_pull_request.cjs" ) ;
2304+ const handler = await main ( { allow_empty : true , assignees : [ "user1" , "user2" ] } ) ;
2305+
2306+ const result = await handler ( { title : "Test PR" , body : "Test body" } , { } ) ;
2307+
2308+ expect ( result . success ) . toBe ( true ) ;
2309+ expect ( result . fallback_used ) . toBe ( true ) ;
2310+ expect ( result . issue_number ) . toBe ( 77 ) ;
2311+ expect ( global . github . rest . issues . create ) . toHaveBeenCalledTimes ( 2 ) ;
2312+ // Second call (without assignees) should have a note in the body
2313+ const secondCall = global . github . rest . issues . create . mock . calls [ 1 ] [ 0 ] ;
2314+ expect ( secondCall . assignees ) . toBeUndefined ( ) ;
2315+ expect ( secondCall . body ) . toContain ( "user1" ) ;
2316+ expect ( secondCall . body ) . toContain ( "user2" ) ;
2317+ expect ( secondCall . body ) . toContain ( "could not be set" ) ;
2318+ } ) ;
2319+ } ) ;
0 commit comments