-
Notifications
You must be signed in to change notification settings - Fork 400
Expand file tree
/
Copy pathcreate_pull_request.cjs
More file actions
2300 lines (2063 loc) · 110 KB
/
create_pull_request.cjs
File metadata and controls
2300 lines (2063 loc) · 110 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// @ts-check
/// <reference types="@actions/github-script" />
/** @type {typeof import("fs")} */
const fs = require("fs");
/** @type {typeof import("crypto")} */
const crypto = require("crypto");
const { updateActivationComment } = require("./update_activation_comment.cjs");
const { pushSignedCommits } = require("./push_signed_commits.cjs");
const { getTrackerID } = require("./get_tracker_id.cjs");
const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs");
const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { replaceTemporaryIdReferences, replaceTemporaryIdReferencesInPatch, getOrGenerateTemporaryId } = require("./temporary_id.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { addExpirationToFooter } = require("./ephemerals.cjs");
const { generateWorkflowIdMarker } = require("./generate_footer.cjs");
const { parseBoolTemplatable } = require("./templatable.cjs");
const { generateFooterWithMessages, getDetectionCautionAlert } = require("./messages_footer.cjs");
const { getBodyHeader } = require("./messages_header.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { normalizeBranchName } = require("./normalize_branch_name.cjs");
const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs");
const { createCheckoutManager } = require("./dynamic_checkout.cjs");
const { getBaseBranch } = require("./get_base_branch.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
const { checkFileProtection } = require("./manifest_file_helpers.cjs");
const { renderTemplateFromFile, renderFilesList, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs");
const { COPILOT_REVIEWER_BOT, FAQ_CREATE_PR_PERMISSIONS_URL } = require("./constants.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs");
const { findAgent, getIssueDetails, assignAgentToIssue } = require("./assign_agent_helpers.cjs");
const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits } = require("./git_helpers.cjs");
const { parseDiffGitHeader: parseDiffGitHeaderPaths, extractDiffGitHeaderEntries } = require("./patch_path_helpers.cjs");
const { resolveAllowedMentionsFromPayload } = require("./resolve_mentions_from_payload.cjs");
const {
MANAGED_FALLBACK_ISSUE_LABEL,
LABEL_MAX_RETRIES,
LABEL_INITIAL_DELAY_MS,
LABEL_MAX_DELAY_MS,
summarizeListForLog,
createBundleTempRef,
isLabelTransientError,
parseAllowedBaseBranches,
isBaseBranchAllowed,
parseStringListConfig,
mergeFallbackIssueLabels,
sanitizeFallbackAssignees,
neutralizeClosingKeywordsForIssueBody,
generatePatchPreview,
buildManifestProtectionCreatePrUrl,
renderManifestProtectionFallbackBody,
} = require("./create_pull_request_helpers.cjs");
/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
*/
/**
* Creates an authenticated GitHub client for copilot assignment on fallback issues.
* Prefers the agent-specific token (GH_AW_ASSIGN_TO_AGENT_TOKEN) because the Copilot
* assignment API requires a PAT rather than a GitHub App token.
*
* Token priority:
* 1. config["github-token"] — explicit per-handler override
* 2. GH_AW_ASSIGN_TO_AGENT_TOKEN — injected by the compiler when copilot is in assignees
* 3. global github — step-level token (fallback when no agent token is available)
*
* @param {Object} config - Handler configuration
* @returns {Promise<Object>} Authenticated GitHub client
*/
async function createCopilotAssignmentClient(config) {
const token = config["github-token"] || process.env.GH_AW_ASSIGN_TO_AGENT_TOKEN;
if (!token) {
core.debug("No dedicated agent token configured — using step-level github client for copilot assignment");
return github;
}
core.info("Using dedicated github client for copilot assignment");
return global.getOctokit(token);
}
/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "create_pull_request";
// NOTE: MANAGED_FALLBACK_ISSUE_LABEL, createBundleTempRef, and summarizeListForLog
// are imported from create_pull_request_helpers.cjs above.
/**
* Attempt automatic recovery for git am add/add conflicts by preferring the patch version.
*
* @param {{ exec: Function, getExecOutput: Function }} execApi - Exec API with git command helpers
* @returns {Promise<{ recovered: boolean, attempted: boolean, errorMessage?: string }>}
*/
async function tryRecoverGitAmAddAddConflict(execApi) {
try {
const unresolvedFilesResult = await execApi.getExecOutput("git", ["diff", "--name-only", "--diff-filter=U", "-z"]);
const unresolvedFiles = unresolvedFilesResult.stdout.split("\0").filter(line => line.length > 0);
core.debug(`Add/add recovery probe unresolved files (${unresolvedFiles.length}): ${summarizeListForLog(unresolvedFiles)}`);
if (unresolvedFiles.length === 0) {
return { recovered: false, attempted: false };
}
const statusPorcelainResult = await execApi.getExecOutput("git", ["status", "--porcelain", "-z"]);
const addAddFiles = new Set(
statusPorcelainResult.stdout
.split("\0")
.filter(line => line.length > 0)
.filter(line => line.startsWith("AA "))
.map(line => line.substring(3))
);
core.debug(`Add/add recovery probe AA files (${addAddFiles.size}): ${summarizeListForLog(Array.from(addAddFiles))}`);
const allConflictsAreAddAdd = unresolvedFiles.every(file => addAddFiles.has(file));
if (!allConflictsAreAddAdd) {
core.debug("Add/add recovery skipped because unresolved conflicts include non-AA entries");
return { recovered: false, attempted: false };
}
core.warning(`Detected add/add conflict(s) for ${unresolvedFiles.join(", ")}; preferring patch version and continuing`);
for (const file of unresolvedFiles) {
try {
const { stdout: unresolvedIndexOutput } = await execApi.getExecOutput("git", ["ls-files", "-u", "--", file]);
let oursBlobSha = "";
let theirsBlobSha = "";
for (const line of unresolvedIndexOutput.split("\n")) {
if (!line.trim()) {
continue;
}
const fields = line.trim().split(/\s+/);
if (fields.length < 3) {
continue;
}
if (fields[2] === "2") {
oursBlobSha = fields[1];
} else if (fields[2] === "3") {
theirsBlobSha = fields[1];
}
}
const getBlobSize = async blobSha => {
if (!blobSha) {
return "unknown";
}
try {
const { stdout } = await execApi.getExecOutput("git", ["cat-file", "-s", blobSha]);
return stdout.trim() || "unknown";
} catch {
return "unknown";
}
};
const oursSize = await getBlobSize(oursBlobSha);
const theirsSize = await getBlobSize(theirsBlobSha);
core.warning(`Resolving add/add conflict for ${file}: ours ${oursBlobSha || "unknown"} (${oursSize} bytes), theirs ${theirsBlobSha || "unknown"} (${theirsSize} bytes); preferring patch version (--theirs)`);
} catch (metadataError) {
core.warning(`Resolving add/add conflict for ${file}; failed to read conflict blob metadata: ${getErrorMessage(metadataError)}. Preferring patch version (--theirs)`);
}
core.debug(`Checking out patch version for add/add conflict file: ${file}`);
await execApi.exec("git", ["checkout", "--theirs", "--", file]);
await execApi.exec("git", ["add", "--", file]);
}
await execApi.exec("git", ["am", "--continue"]);
core.info("Patch applied successfully after resolving add/add conflict(s)");
return { recovered: true, attempted: true };
} catch (recoveryError) {
core.debug(`Add/add recovery threw: ${getErrorMessage(recoveryError)}`);
return { recovered: false, attempted: true, errorMessage: getErrorMessage(recoveryError) };
}
}
/**
* Apply a git bundle to a local branch without fetching directly into the branch ref.
* Fetching directly into refs/heads/<branch> fails when that branch is currently checked out.
*
* @param {string} bundleFilePath - Path to the bundle file
* @param {string} branchName - Target branch name
* @param {string} originalAgentBranch - Original source branch name from the agent, if different
* @param {{ exec: Function, getExecOutput: Function }} execApi - GitHub Actions exec API
* @returns {Promise<void>}
*/
async function applyBundleToBranch(bundleFilePath, branchName, originalAgentBranch, execApi) {
let bundleBranchRef = `refs/heads/${originalAgentBranch || branchName}`;
const bundleTargetRef = `refs/heads/${branchName}`;
const bundleTempRef = createBundleTempRef(branchName);
try {
await ensureFullHistoryForBundle(execApi);
core.info(`Applying bundle ${bundleFilePath} to ${bundleTargetRef} using temp ref ${bundleTempRef} from ${bundleBranchRef}`);
// Fetch from bundle into a temporary ref, then update the target branch.
// bundleBranchRef is the source ref inside the bundle (typically refs/heads/<agent-branch>).
// Use getExecOutput with ignoreReturnCode so we can read the actual stderr from git —
// exec() only throws "The process '...' failed with exit code 1" which loses the
// "lacks these prerequisite commits" text needed for the recovery path below.
core.info(`Attempting bundle fetch from ${bundleBranchRef} into ${bundleTempRef}`);
const initialBundleFetch = await execApi.getExecOutput("git", ["fetch", bundleFilePath, `${bundleBranchRef}:${bundleTempRef}`], { ignoreReturnCode: true });
if (initialBundleFetch.exitCode !== 0) {
const initialFetchErrorOutput = initialBundleFetch.stderr || `exit code ${initialBundleFetch.exitCode}`;
// Recovery path for bundle prerequisite failures: fetch missing prerequisite
// commit objects, then retry with the original bundle ref.
// This handles the race where main advanced between agent-time and safe_outputs-time:
// the bundle's base commit may not be reachable from a fetch-depth:1 shallow clone
// even after --unshallow (e.g. when the commit is on a ref not in the fetch refspec).
const prerequisiteCommits = extractBundlePrerequisiteCommits(initialFetchErrorOutput);
if (prerequisiteCommits.length > 0) {
core.warning(`Bundle fetch with ${bundleBranchRef} failed due to ${prerequisiteCommits.length} missing prerequisite commit(s); fetching prerequisites from origin and retrying`);
core.info(`Prerequisite commits: ${summarizeListForLog(prerequisiteCommits)}`);
core.info(`Fetching ${prerequisiteCommits.length} prerequisite commit(s) from origin`);
await execApi.exec("git", ["fetch", "origin", ...prerequisiteCommits]);
core.info("Fetched prerequisite commits from origin successfully");
try {
core.info(`Retrying bundle fetch from ${bundleBranchRef} into ${bundleTempRef} after prerequisite recovery`);
await execApi.exec("git", ["fetch", bundleFilePath, `${bundleBranchRef}:${bundleTempRef}`]);
core.info("Bundle fetch retry succeeded after prerequisite recovery");
} catch (retryError) {
throw new Error(`Bundle fetch failed after fetching ${prerequisiteCommits.length} prerequisite commit(s): ${retryError instanceof Error ? retryError.message : String(retryError)}`, { cause: retryError });
}
} else {
// Fallback: resolve the source ref directly from the bundle contents.
// Some agents may emit a JSONL branch name that differs from the ref embedded in the bundle.
core.warning(`Bundle fetch with ${bundleBranchRef} failed: ${initialFetchErrorOutput}; resolving branch ref from bundle heads`);
core.info(`Inspecting bundle heads from ${bundleFilePath}`);
const { stdout: bundleHeadsOutput } = await execApi.getExecOutput("git", ["bundle", "list-heads", bundleFilePath]);
const branchRefs = bundleHeadsOutput
.split("\n")
.map(line => line.trim().split(/\s+/)[1] || "")
.filter(ref => /^refs\/heads\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/.test(ref));
core.info(`Bundle list-heads returned ${branchRefs.length} candidate branch ref(s): ${summarizeListForLog(branchRefs)}`);
if (branchRefs.length === 1) {
bundleBranchRef = branchRefs[0];
core.info(`Resolved bundle source ref from list-heads: ${bundleBranchRef}`);
core.info(`Fetching resolved bundle ref ${bundleBranchRef} into ${bundleTempRef}`);
await execApi.exec("git", ["fetch", bundleFilePath, `${bundleBranchRef}:${bundleTempRef}`]);
} else {
throw new Error(`Failed to resolve bundle branch ref from list-heads: expected exactly 1 refs/heads entry, found ${branchRefs.length}`);
}
}
}
core.info(`Fetched bundle to ${bundleTempRef}`);
await execApi.exec("git", ["update-ref", bundleTargetRef, bundleTempRef]);
core.info(`Created local branch ${branchName} from bundle`);
await execApi.exec("git", ["checkout", branchName]);
// Ensure the working tree matches the new HEAD in case checkout left any index/working tree drift.
await execApi.exec("git", ["reset", "--hard"]);
core.info(`Checked out branch ${branchName} from bundle`);
} finally {
try {
await execApi.exec("git", ["update-ref", "-d", bundleTempRef]);
} catch (cleanupError) {
// Non-fatal cleanup
core.warning(`Non-fatal cleanup: failed to delete temporary bundle ref ${bundleTempRef}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
}
}
}
/**
* Rewrites the current branch to a single non-merge commit relative to origin/<baseBranch>.
* This is used as a recovery path when signed commit replay rejects merge commit topology.
*
* @param {string} baseBranch
* @param {{ exec: Function, getExecOutput: Function }} execApi
* @returns {Promise<void>}
*/
async function rewriteBundleBranchAsSingleCommit(baseBranch, execApi) {
const baseRef = `origin/${baseBranch}`;
const { stdout: originalHeadOut } = await execApi.getExecOutput("git", ["rev-parse", "HEAD"]);
const originalHead = originalHeadOut.trim();
if (!originalHead) {
throw new Error("Could not resolve current HEAD before bundle rewrite");
}
let commitHeadline = "Apply bundled create_pull_request changes";
try {
const { stdout: headlineOut } = await execApi.getExecOutput("git", ["log", "-1", "--format=%s", "HEAD"]);
if (headlineOut.trim()) {
commitHeadline = headlineOut.trim();
}
} catch {
// Non-fatal: use default commit headline.
}
core.warning(`Rewriting bundled commits to a single linear commit for signed push compatibility (base: ${baseRef})`);
try {
await execApi.exec("git", ["reset", "--soft", baseRef]);
const { stdout: stagedFilesOut } = await execApi.getExecOutput("git", ["diff", "--cached", "--name-only"]);
if (!stagedFilesOut.trim()) {
throw new Error(`No staged changes found after soft reset to ${baseRef}`);
}
await execApi.exec("git", ["commit", "-m", commitHeadline]);
const { stdout: rewrittenHeadOut } = await execApi.getExecOutput("git", ["rev-parse", "HEAD"]);
const rewrittenHead = rewrittenHeadOut.trim();
core.info(`Bundle rewrite completed (old HEAD: ${originalHead}, new HEAD: ${rewrittenHead})`);
} catch (rewriteError) {
try {
await execApi.exec("git", ["reset", "--hard", originalHead]);
core.warning(`Bundle rewrite failed; restored original HEAD ${originalHead}`);
} catch (restoreError) {
core.warning(`Bundle rewrite rollback failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`);
}
throw new Error(`Failed to rewrite bundled commits for signed push retry: ${rewriteError instanceof Error ? rewriteError.message : String(rewriteError)}`, {
cause: rewriteError,
});
}
}
// NOTE: isLabelTransientError, LABEL_MAX_RETRIES, LABEL_INITIAL_DELAY_MS, LABEL_MAX_DELAY_MS,
// parseAllowedBaseBranches, isBaseBranchAllowed, parseStringListConfig, mergeFallbackIssueLabels,
// sanitizeFallbackAssignees, neutralizeClosingKeywordsForIssueBody, generatePatchPreview,
// buildManifestProtectionCreatePrUrl, and renderManifestProtectionFallbackBody
// are imported from create_pull_request_helpers.cjs above.
/**
* Creates a fallback GitHub issue, retrying on rate-limit and other transient errors
* (with exponential back-off) and retrying without assignees if the API rejects them.
* This ensures fallback issue creation remains reliable even if an assignee username
* is invalid, the repository does not have that collaborator, or the installation token
* quota is temporarily exhausted.
* @param {object} githubClient - Authenticated GitHub client
* @param {{owner: string, repo: string}} repoParts - Repository owner and name
* @param {string} title - Issue title
* @param {string} body - Issue body
* @param {string[]} labels - Issue labels
* @param {string[] | null} assignees - Sanitized assignees (null = omit field)
* @returns {Promise<any>}
*/
async function createFallbackIssue(githubClient, repoParts, title, body, labels, assignees) {
const payload = {
owner: repoParts.owner,
repo: repoParts.repo,
title,
body,
labels,
...(assignees && assignees.length > 0 && { assignees }),
};
return withRetry(
async () => {
try {
return await githubClient.rest.issues.create(payload);
} catch (error) {
const status = typeof error === "object" && error !== null && "status" in error ? error.status : undefined;
const message = getErrorMessage(error).toLowerCase();
const isAssigneeError = status === 422 && (message.includes("assignee") || message.includes("assignees") || message.includes("unprocessable"));
if (isAssigneeError && payload.assignees && payload.assignees.length > 0) {
const removedAssignees = payload.assignees.join(", ");
core.warning(`Fallback issue creation failed due to assignee error, retrying without assignees: ${getErrorMessage(error)}`);
// Mutate payload in-place so that any subsequent withRetry attempts also
// omit assignees and do not re-trigger the same 422 path.
delete payload.assignees;
payload.body = `${payload.body}\n\n> [!NOTE]\n> Assignees (${removedAssignees}) could not be set on this issue due to an API error.`;
return await githubClient.rest.issues.create(payload);
}
throw error;
}
},
RATE_LIMIT_RETRY_CONFIG,
`create fallback issue in ${repoParts.owner}/${repoParts.repo}`
);
}
/**
* Maximum limits for pull request parameters to prevent resource exhaustion.
* These limits align with GitHub's API constraints and security best practices.
*/
/** @type {number} Default maximum number of unique files allowed per pull request.
* Can be overridden via the `max-patch-files` safe-outputs config option. */
const MAX_FILES = 100;
/**
* Parses one `diff --git` header line and returns the preferred file path key.
*
* @param {string} headerLine
* @returns {string|null}
*/
function parseDiffGitHeader(headerLine) {
const parsed = parseDiffGitHeaderPaths(headerLine);
return parsed.newPath || parsed.oldPath || null;
}
/**
* Counts the number of unique file paths touched by a git patch.
*
* `git format-patch` emits one `diff --git` header per (commit, file), so the
* same file modified across multiple commits will appear multiple times. The
* file-count safety limit counts unique files (i.e. how many distinct files
* this push touches), not raw header occurrences.
*
* Headers whose paths cannot be parsed contribute one *synthetic* entry each
* to the unique-file set, so a malformed or quoted-with-escapes header line
* can never silently bypass the limit (we conservatively over-count rather
* than under-count when in doubt).
*
* @param {string} patchContent - Patch content to inspect (may be empty)
* @returns {number} Number of unique file paths referenced in the patch
*/
function countUniquePatchFiles(patchContent) {
if (!patchContent || !patchContent.trim()) {
return 0;
}
const files = new Set();
const entries = extractDiffGitHeaderEntries(patchContent);
let unparseableIdx = 0;
for (const entry of entries) {
const path = entry.newPath || entry.oldPath;
if (path) {
files.add(path);
} else {
files.add(`__unparseable_header_${entry.headerIndex}_${unparseableIdx++}`);
}
}
return files.size;
}
/**
* Enforces maximum limits on pull request parameters to prevent resource exhaustion attacks.
* Per Safe Outputs specification requirement SEC-003, limits must be enforced before API calls.
*
* The file-count check measures the number of *unique* files in the patch (not
* the number of `diff --git` headers, which can be inflated when the patch
* contains multiple commits touching the same file).
*
* @param {string} patchContent - Patch content to validate
* @param {number} [maxFiles=MAX_FILES] - Maximum number of unique files allowed
* @throws {Error} When any limit is exceeded, with error code E003 and details
*/
function enforcePullRequestLimits(patchContent, maxFiles = MAX_FILES) {
if (!patchContent || !patchContent.trim()) {
return;
}
const limit = Number.isFinite(maxFiles) && maxFiles > 0 ? maxFiles : MAX_FILES;
const fileCount = countUniquePatchFiles(patchContent);
// Check file count - max limit exceeded check
if (fileCount > limit) {
throw new Error(
`E003: Cannot create pull request with more than ${limit} files (received ${fileCount}). ` +
`To increase the limit, set \`max-patch-files: ${fileCount}\` (or higher) under ` +
`\`safe-outputs.create-pull-request\` in your workflow frontmatter.`
);
}
}
// NOTE: generatePatchPreview is imported from create_pull_request_helpers.cjs above.
/**
* Check whether the remote branch already exists and, if so, either reuse it
* (when preserve-branch-name and recreate-ref are enabled, by force-deleting
* the remote ref so the subsequent push recreates it from the local HEAD) or rename
* the local branch by appending a random hex suffix.
*
* The "force-delete then recreate" semantic is gated behind `recreate-ref`
* because the existing remote branch may have diverged from the local HEAD
* (e.g. a long-lived branch whose previous PR was merged and is now behind
* the base branch). Deleting the ref first lets `pushSignedCommits` recreate
* the branch at the local commit's parent OID and replay only the local
* commits via the GraphQL `createCommitOnBranch` mutation, which is what
* users intend by enabling `recreate-ref` on a reusable branch.
*
* When `preserve-branch-name: true` but `recreate-ref: false` (default),
* an existing remote branch results in an error so the caller falls back to
* the configured fallback (e.g. opening an issue) rather than silently
* destroying the remote ref.
*
* @param {string} branchName - Current local branch name.
* @param {boolean} preserveBranchName - Whether preserve-branch-name is enabled.
* @param {object} [options] - Additional options.
* @param {boolean} [options.recreateRef] - Whether recreate-ref is enabled.
* Only meaningful when preserveBranchName is true.
* @param {object} [options.githubClient] - Authenticated Octokit client used to delete the
* existing remote ref when recreate-ref is enabled.
* @param {string} [options.owner] - Repository owner for the deleteRef call.
* @param {string} [options.repo] - Repository name for the deleteRef call.
* @returns {Promise<string>} The (possibly renamed) branch name to use going forward.
*/
async function handleRemoteBranchCollision(branchName, preserveBranchName, options = {}) {
let remoteBranchExists = false;
try {
const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`);
if (stdout.trim()) {
remoteBranchExists = true;
}
} catch (checkError) {
core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`);
}
if (!remoteBranchExists) {
return branchName;
}
if (preserveBranchName) {
const { recreateRef, githubClient, owner, repo } = options;
if (!recreateRef) {
// preserve-branch-name asked us to keep the exact branch name, but
// recreate-ref is not enabled, so we cannot silently destroy the
// existing remote ref. Surface an error so the caller falls back to the
// configured fallback (e.g. opening an issue).
throw new Error(
`Remote branch "${branchName}" already exists and preserve-branch-name is enabled. ` + `Set recreate-ref: true to force-delete and recreate the remote ref, or disable ` + `preserve-branch-name to allow renaming the branch.`
);
}
// Reuse the existing branch by deleting the remote ref so the subsequent
// push recreates it from the local HEAD (force-push semantics). This is the
// intended behavior when recreate-ref is enabled for long-lived
// reusable branches whose previous PR was merged.
if (!githubClient || !owner || !repo) {
throw new Error(
`Remote branch "${branchName}" already exists and recreate-ref is enabled, ` +
`but no GitHub client was provided to delete the existing remote ref. This is an ` +
`internal error: the caller must pass githubClient, owner, and repo to reuse the branch.`
);
}
core.warning(`Remote branch ${branchName} already exists - reusing it (recreate-ref enabled, force-deleting remote ref)`);
let deleteBlocked = false;
try {
await githubClient.rest.git.deleteRef({ owner, repo, ref: `heads/${branchName}` });
core.info(`Deleted remote branch ${branchName} to reuse it`);
} catch (deleteError) {
/** @type {any} */
const err = deleteError;
const status = err && typeof err === "object" ? err.status : undefined;
const message = err && typeof err === "object" ? String(err.message || "") : "";
// 422 "Reference does not exist" can happen if the branch was deleted concurrently;
// treat that as success and continue.
if (status === 422 && /Reference does not exist/i.test(message)) {
core.info(`Remote branch ${branchName} was already deleted concurrently; continuing`);
} else if (status === 422 && (/Cannot delete this branch/i.test(message) || /Repository rule violations/i.test(message))) {
// A branch protection rule (e.g. a ruleset that blocks deletion) prevented
// the delete. Fall back gracefully by appending a random suffix to the branch
// name rather than failing hard, so a PR can still be created.
core.warning(`Remote branch "${branchName}" cannot be deleted due to branch protection rules (recreate-ref blocked). ` + `Falling back to rename with random suffix.`);
deleteBlocked = true;
} else {
throw new Error(`Failed to delete existing remote branch "${branchName}" for reuse with recreate-ref: ${message || String(err)}`);
}
}
if (!deleteBlocked) {
return branchName;
}
}
core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
const extraHex = crypto.randomBytes(4).toString("hex");
const oldBranch = branchName;
const renamedBranch = `${branchName}-${extraHex}`;
// Rename local branch
await exec.exec(`git branch -m ${oldBranch} ${renamedBranch}`);
core.info(`Renamed branch to ${renamedBranch}`);
return renamedBranch;
}
/**
* Main handler factory for create_pull_request
* Returns a message handler function that processes individual create_pull_request messages
* @type {HandlerFactoryFunction}
*/
async function main(config = {}) {
// Extract configuration
const rawBranchPrefix = config.branch_prefix || "";
const normalizedBranchPrefix = normalizeBranchName(rawBranchPrefix);
if (rawBranchPrefix && normalizedBranchPrefix !== rawBranchPrefix) {
const branchPrefixWarning = [
`Branch prefix "${rawBranchPrefix}" contains characters that are invalid in a git ref.`,
`Using normalized prefix: "${normalizedBranchPrefix}".`,
"Update branch-prefix in the workflow configuration to avoid this warning.",
].join(" ");
core.warning(branchPrefixWarning);
}
const branchPrefix = normalizedBranchPrefix;
const titlePrefix = config.title_prefix || "";
const envLabels = parseStringListConfig(config.labels);
const configFallbackLabels = parseStringListConfig(config.fallback_labels);
const configReviewers = parseStringListConfig(config.reviewers);
const configTeamReviewers = parseStringListConfig(config.team_reviewers);
const rawAssignees = parseStringListConfig(config.assignees);
const hasCopilotInAssignees = rawAssignees.some(a => a.toLowerCase() === "copilot");
const configAssignees = sanitizeFallbackAssignees(rawAssignees);
const draftDefault = parseBoolTemplatable(config.draft, true);
const ifNoChanges = config.if_no_changes || "warn";
const allowEmpty = parseBoolTemplatable(config.allow_empty, false);
const autoMerge = parseBoolTemplatable(config.auto_merge, false);
const preserveBranchName = config.preserve_branch_name === true;
const recreateRef = config.recreate_ref === true;
const signedCommits = config.signed_commits !== false;
const expiresHours = config.expires ? parseInt(String(config.expires), 10) : 0;
const maxCount = config.max || 1; // PRs are typically limited to 1
const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024;
const maxFiles = config.max_patch_files ? parseInt(String(config.max_patch_files), 10) : MAX_FILES;
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
const allowedBaseBranches = parseAllowedBaseBranches(config.allowed_base_branches);
const githubClient = await createAuthenticatedGitHubClient(config);
let allowedMentionAliases = [];
if (Array.isArray(config.allowedMentionAliases)) {
allowedMentionAliases = config.allowedMentionAliases;
} else if (config.mentions != null) {
allowedMentionAliases = await resolveAllowedMentionsFromPayload(context, githubClient, core, config.mentions);
}
// Check if copilot assignment is enabled for fallback issues
const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true";
// Lazily-initialised client for copilot assignment (only allocated when needed).
// Uses GH_AW_ASSIGN_TO_AGENT_TOKEN (agent token preference chain) when available,
// otherwise falls back to the step-level github object.
/** @type {Object|null} */
let copilotClient = null;
/**
* Assigns copilot to a fallback issue using agent helpers, if copilot was requested
* in the assignees config and the GH_AW_ASSIGN_COPILOT env var is set.
* A no-op when either condition is false. The copilotClient is initialised lazily
* on the first call and reused for subsequent issues.
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {number} issueNumber - Fallback issue number
*/
async function assignCopilotToFallbackIssueIfEnabled(owner, repo, issueNumber) {
if (!hasCopilotInAssignees || !assignCopilot) return;
if (!copilotClient) {
copilotClient = await createCopilotAssignmentClient(config);
}
core.info(`Assigning copilot coding agent to fallback issue #${issueNumber} in ${owner}/${repo}...`);
try {
const agentId = await findAgent(owner, repo, "copilot", copilotClient);
if (!agentId) {
core.warning(`copilot coding agent is not available for ${owner}/${repo}`);
return;
}
const issueDetails = await getIssueDetails(owner, repo, issueNumber, copilotClient);
if (!issueDetails) {
core.warning(`Failed to get issue details for copilot assignment of fallback issue #${issueNumber}`);
return;
}
if (issueDetails.currentAssignees.some(a => a.id === agentId)) {
core.info(`copilot is already assigned to fallback issue #${issueNumber}`);
return;
}
const assigned = await assignAgentToIssue(
issueDetails.issueId,
agentId,
issueDetails.currentAssignees,
"copilot",
null, // allowedAgents — not restricted for fallback issues
null, // pullRequestRepoId — not applicable (issue, not PR)
null, // model — not applicable
null, // customAgent — not applicable
null, // customInstructions — not applicable
null, // baseBranch — not applicable
copilotClient
);
if (assigned) {
core.info(`Successfully assigned copilot coding agent to fallback issue #${issueNumber}`);
} else {
core.warning(`Failed to assign copilot to fallback issue #${issueNumber}`);
}
} catch (error) {
core.warning(`Failed to assign copilot to fallback issue #${issueNumber}: ${getErrorMessage(error)}`);
}
}
// Base branch from config (if set) - validated at factory level if explicit
// Dynamic base branch resolution happens per-message after resolving the actual target repo
const configBaseBranch = config.base_branch || null;
// SECURITY: If base branch is explicitly configured, validate it at factory level
if (configBaseBranch) {
const normalizedConfigBase = normalizeBranchName(configBaseBranch);
if (!normalizedConfigBase) {
throw new Error(`Invalid baseBranch: sanitization resulted in empty string (original: "${configBaseBranch}")`);
}
if (configBaseBranch !== normalizedConfigBase) {
throw new Error(`Invalid baseBranch: contains invalid characters (original: "${configBaseBranch}", normalized: "${normalizedConfigBase}")`);
}
}
const includeFooter = parseBoolTemplatable(config.footer, true);
const fallbackAsIssue = config.fallback_as_issue !== false; // Default to true (fallback enabled)
const autoCloseIssue = parseBoolTemplatable(config.auto_close_issue, true); // Default to true (auto-close enabled)
// Environment validation - fail early if required variables are missing
const workflowId = process.env.GH_AW_WORKFLOW_ID;
if (!workflowId) {
throw new Error("GH_AW_WORKFLOW_ID environment variable is required");
}
// Extract triggering issue number from context (for auto-linking PRs to issues)
const triggeringIssueNumber = typeof context !== "undefined" && context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
// Check if we're in staged mode
const isStaged = isStagedMode(config);
core.info(`Base branch: ${configBaseBranch || "(dynamic - resolved per target repo)"}`);
core.info(`Default target repo: ${defaultTargetRepo}`);
if (allowedRepos.size > 0) {
core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
}
if (allowedBaseBranches.size > 0) {
core.info(`Allowed base branches: ${Array.from(allowedBaseBranches).join(", ")}`);
}
if (envLabels.length > 0) {
core.info(`Default labels: ${envLabels.join(", ")}`);
}
if (configFallbackLabels.length > 0) {
core.info(`Configured fallback issue labels: ${configFallbackLabels.join(", ")}`);
}
if (configReviewers.length > 0) {
core.info(`Configured reviewers: ${configReviewers.join(", ")}`);
}
if (configTeamReviewers.length > 0) {
core.info(`Configured team reviewers: ${configTeamReviewers.join(", ")}`);
}
if (configAssignees && configAssignees.length > 0) {
core.info(`Configured assignees (for fallback issues): ${configAssignees.join(", ")}`);
}
if (titlePrefix) {
core.info(`Title prefix: ${titlePrefix}`);
}
core.info(`Draft default: ${draftDefault}`);
core.info(`If no changes: ${ifNoChanges}`);
core.info(`Allow empty: ${allowEmpty}`);
core.info(`Auto-merge: ${autoMerge}`);
core.info(`Signed commits: ${signedCommits}`);
if (expiresHours > 0) {
core.info(`Pull requests expire after: ${expiresHours} hours`);
}
core.info(`Max count: ${maxCount}`);
core.info(`Max patch size: ${maxSizeKb} KB`);
core.info(`Max patch files: ${maxFiles}`);
// Track how many items we've processed for max limit
let processedCount = 0;
// Create checkout manager for multi-repo support
// Token is available via GITHUB_TOKEN environment variable (set by the workflow job)
const checkoutToken = process.env.GITHUB_TOKEN;
const checkoutManager = checkoutToken ? createCheckoutManager(checkoutToken, { defaultBaseBranch: configBaseBranch }) : null;
// Log multi-repo support status
if (allowedRepos.size > 0 && checkoutManager) {
core.info(`Multi-repo support enabled: can switch between repos in allowed-repos list`);
} else if (allowedRepos.size > 0 && !checkoutManager) {
core.warning(`Multi-repo support disabled: GITHUB_TOKEN not available for dynamic checkout`);
}
/**
* Message handler function that processes a single create_pull_request message
* @param {Object} message - The create_pull_request message to process
* @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
* @returns {Promise<Object>} Result with success/error status and PR details
*/
return async function handleCreatePullRequest(message, resolvedTemporaryIds) {
// Check if we've hit the max limit
if (processedCount >= maxCount) {
core.warning(`Skipping create_pull_request: max count of ${maxCount} reached`);
return {
success: false,
error: `Max count of ${maxCount} reached`,
};
}
processedCount++;
const pullRequestItem = message;
const tempIdResult = getOrGenerateTemporaryId(pullRequestItem, "pull request");
if (tempIdResult.error) {
core.warning(`Skipping create_pull_request: ${tempIdResult.error}`);
return { success: false, error: tempIdResult.error };
}
const temporaryId = tempIdResult.temporaryId;
core.info(`Processing create_pull_request: title=${pullRequestItem.title || "No title"}, bodyLength=${pullRequestItem.body?.length || 0}`);
// Determine the patch file path from the message (set by the MCP server handler)
const patchFilePath = pullRequestItem.patch_path;
core.info(`Patch file path: ${patchFilePath || "(not set)"}`);
// Determine the bundle file path from the message (set when patch-format: bundle is configured)
const bundleFilePath = pullRequestItem.bundle_path;
if (bundleFilePath) {
core.info(`Bundle file path: ${bundleFilePath}`);
}
// Resolve and validate target repository
const repoResult = resolveAndValidateRepo(pullRequestItem, defaultTargetRepo, allowedRepos, "pull request");
if (!repoResult.success) {
core.warning(`Skipping pull request: ${repoResult.error}`);
return {
success: false,
error: repoResult.error,
};
}
const { repo: itemRepo, repoParts } = repoResult;
core.info(`Target repository: ${itemRepo}`);
// Resolve base branch for this target repository
// Use config value if set, otherwise resolve dynamically for the specific target repo
// Dynamic resolution is needed for issue_comment events on PRs where the base branch
// is not available in GitHub Actions expressions and requires an API call
// NOTE: Must be resolved before checkout so cross-repo checkout uses the correct branch
let baseBranch = configBaseBranch || (await getBaseBranch(repoParts));
// Optional agent-provided base branch override.
// The default base branch is always implicitly allowed even without allowed_base_branches.
// Overriding to a different branch requires allowed_base_branches to be configured.
if (typeof pullRequestItem.base === "string" && pullRequestItem.base.trim() !== "") {
const requestedBaseBranchRaw = pullRequestItem.base.trim();
const requestedBaseBranchForLog = JSON.stringify(requestedBaseBranchRaw);
core.info(`Base branch override requested: ${requestedBaseBranchForLog}`);
if (requestedBaseBranchRaw === baseBranch && allowedBaseBranches.size === 0) {
// The agent explicitly specified the current base branch with no allowlist configured —
// this is a no-op, not a true override, so no allowlist check is needed.
core.info(`Base branch ${requestedBaseBranchForLog} matches the default base branch, no override needed`);
} else {
if (allowedBaseBranches.size === 0) {
core.warning(`Rejecting base branch override ${requestedBaseBranchForLog}: allowed-base-branches is not configured`);
return {
success: false,
error: "Base branch override is not allowed. Configure safe-outputs.create-pull-request.allowed-base-branches to allow per-run base overrides.",
};
}
const requestedBaseBranch = normalizeBranchName(requestedBaseBranchRaw);
if (!requestedBaseBranch) {
core.warning(`Rejecting base branch override ${requestedBaseBranchForLog}: sanitization resulted in empty branch name`);
return {
success: false,
error: `Invalid base branch override: sanitization resulted in empty string (original: "${requestedBaseBranchRaw}")`,
};
}
if (requestedBaseBranchRaw !== requestedBaseBranch) {
core.warning(`Rejecting base branch override ${requestedBaseBranchForLog}: sanitized value '${requestedBaseBranch}' does not match original`);
return {
success: false,
error: `Invalid base branch override: contains invalid characters (original: "${requestedBaseBranchRaw}", normalized: "${requestedBaseBranch}")`,
};
}
const requestedBaseBranchSafeForLog = JSON.stringify(requestedBaseBranch);
if (!isBaseBranchAllowed(requestedBaseBranch, allowedBaseBranches)) {
core.warning(`Rejecting base branch override ${requestedBaseBranchSafeForLog}: does not match allowed patterns (${Array.from(allowedBaseBranches).join(", ")})`);
return {
success: false,
error: `Base branch override '${requestedBaseBranch}' is not allowed. Allowed patterns: ${Array.from(allowedBaseBranches).join(", ")}`,
};
}
core.info(`Base branch override accepted: ${requestedBaseBranchSafeForLog}`);
baseBranch = requestedBaseBranch;
core.info(`Using agent-provided base branch override: ${baseBranch}`);
}
}
// Multi-repo support: Switch checkout to target repo if different from current
// This enables creating PRs in multiple repos from a single workflow run
if (checkoutManager && itemRepo) {
const switchResult = await checkoutManager.switchTo(itemRepo, { baseBranch });
if (!switchResult.success) {
core.warning(`Failed to switch to repository ${itemRepo}: ${switchResult.error}`);
return {
success: false,
error: `Failed to checkout repository ${itemRepo}: ${switchResult.error}`,
};
}
if (switchResult.switched) {
core.info(`Switched checkout to repository: ${itemRepo}`);
}
}
// SECURITY: Sanitize dynamically resolved base branch to prevent shell injection
const originalBaseBranch = baseBranch;
baseBranch = normalizeBranchName(baseBranch);
if (!baseBranch) {
return {
success: false,
error: `Invalid base branch: sanitization resulted in empty string (original: "${originalBaseBranch}")`,
};
}
if (originalBaseBranch !== baseBranch) {
return {
success: false,
error: `Invalid base branch: contains invalid characters (original: "${originalBaseBranch}", normalized: "${baseBranch}")`,
};
}
core.info(`Base branch for ${itemRepo}: ${baseBranch}`);
// Check if patch file exists and has valid content.
// Always require patch content for policy enforcement, even when bundle transport
// is used for apply-time commit transport.
const hasBundleFile = !!(bundleFilePath && fs.existsSync(bundleFilePath));
const hasPatchFile = !!(patchFilePath && fs.existsSync(patchFilePath));
if (!hasPatchFile) {
// If allow-empty is enabled, we can proceed without a patch file
if (allowEmpty) {
core.info("No patch file found, but allow-empty is enabled - will create empty PR");
} else {
const message = "No patch file found - cannot create pull request without changes";
// If in staged mode, still show preview
if (isStaged) {
let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
summaryContent += `**Status:** ⚠️ No patch file found\n\n`;
summaryContent += `**Message:** ${message}\n\n`;
// Write to step summary
await core.summary.addRaw(summaryContent).write();
core.info("📝 Pull request creation preview written to step summary (no patch file)");
return { success: true, staged: true };
}
switch (ifNoChanges) {
case "error":
return { success: false, error: message };
case "ignore":
// Silent success - no console output
return { success: false, skipped: true };
case "warn":
default:
core.warning(message);
return { success: false, error: message, skipped: true };
}
}
}
let patchContent = "";
let isEmpty = true;
if (hasPatchFile) {
patchContent = fs.readFileSync(patchFilePath, "utf8");
isEmpty = !patchContent || !patchContent.trim();
}
// Enforce max limits on patch before processing.
// Count files once here so the catch block can reuse the value without re-parsing.
const patchFileCount = countUniquePatchFiles(patchContent);
try {
enforcePullRequestLimits(patchContent, maxFiles);
} catch (error) {
const errorMessage = getErrorMessage(error);
core.warning(`Pull request limit exceeded: ${errorMessage}`);
// In staged mode, show a preview instead of performing API side effects
if (isStaged) {
let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n";
summaryContent += "The following pull request would be created if staged mode was disabled:\n\n";
summaryContent += `**Status:** ⚠️ Patch file limit exceeded\n\n`;
summaryContent += `**Message:** ${errorMessage}\n\n`;
await core.summary.addRaw(summaryContent).write();
core.info("📝 Pull request creation preview written to step summary (file limit exceeded)");
return { success: true, staged: true };
}
if (!fallbackAsIssue) {
return { success: false, error: errorMessage };
}
// Surface the limit error in a fallback issue so it appears in the agent failure
// issue/comment thread and the workflow operator knows exactly how to fix it.
const rawFallbackTitle = pullRequestItem.title?.trim() || "Agent Output";
const fallbackTitle = applyTitlePrefix(sanitizeTitle(rawFallbackTitle, titlePrefix), titlePrefix);
const fallbackLabels = mergeFallbackIssueLabels(configFallbackLabels.length > 0 ? configFallbackLabels : envLabels);
const fallbackTemplatePath = getPromptPath("e003_file_limit_fallback.md");
const fallbackBody = renderTemplateFromFile(fallbackTemplatePath, {
error_message: errorMessage,
suggested_limit: patchFileCount,
});
try {
const { data: issue } = await createFallbackIssue(githubClient, repoParts, fallbackTitle, fallbackBody, fallbackLabels, configAssignees);
core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`);
await assignCopilotToFallbackIssueIfEnabled(repoParts.owner, repoParts.repo, issue.number);
await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue");
return {
success: true,
fallback_used: true,
issue_number: issue.number,
issue_url: issue.html_url,
};
} catch (issueError) {
const combinedError = `Pull request limit exceeded and failed to create fallback issue. Limit error: ${errorMessage}. Issue error: ${getErrorMessage(issueError)}`;
core.error(combinedError);
return { success: false, error: combinedError };
}
}
// Check for actual error conditions (but allow empty patches as valid noop)
if (patchContent.includes("Failed to generate patch")) {
// If allow-empty is enabled, ignore patch errors and proceed
if (allowEmpty) {
core.info("Patch file contains error, but allow-empty is enabled - will create empty PR");
patchContent = "";
isEmpty = true;
} else {
const message = "Patch file contains error message - cannot create pull request without changes";