-
Notifications
You must be signed in to change notification settings - Fork 343
Expand file tree
/
Copy pathcreate_pull_request.cjs
More file actions
1667 lines (1458 loc) · 75 KB
/
create_pull_request.cjs
File metadata and controls
1667 lines (1458 loc) · 75 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 { getErrorMessage } = require("./error_helpers.cjs");
const { replaceTemporaryIdReferences, 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 } = require("./messages_footer.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, buildProtectedFileList, encodePathSegments } = require("./messages_core.cjs");
const { COPILOT_REVIEWER_BOT, FAQ_CREATE_PR_PERMISSIONS_URL, MAX_ASSIGNEES } = require("./constants.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { withRetry, isTransientError } = require("./error_recovery.cjs");
const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs");
const { findAgent, getIssueDetails, assignAgentToIssue } = require("./assign_agent_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");
const { getOctokit } = await import("@actions/github");
return getOctokit(token);
}
/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "create_pull_request";
/** @type {string} Label always added to fallback issues so the triage system can find them */
const MANAGED_FALLBACK_ISSUE_LABEL = "agentic-workflows";
/**
* Determines if a label API error is transient and worth retrying.
* Returns true for:
* - The GitHub race condition where a newly-created PR's node ID is not immediately
* resolvable via the REST/GraphQL bridge (unprocessable validation error).
* - Any standard transient error matched by {@link isTransientError} (network issues,
* rate limits, 5xx gateway errors, etc.).
* @param {any} error - The error to check
* @returns {boolean} True if the error is transient and should be retried
*/
function isLabelTransientError(error) {
const msg = getErrorMessage(error);
if (msg.includes("Could not resolve to a node with the global id")) {
return true;
}
return isTransientError(error);
}
/** @type {number} Number of retry attempts for label operations */
const LABEL_MAX_RETRIES = 3;
/** @type {number} Initial delay in ms before the first label retry (3 seconds) */
const LABEL_INITIAL_DELAY_MS = 3000;
/**
* Merges the required fallback label with any workflow-configured labels,
* deduplicating and filtering empty values.
* @param {string[]} [labels]
* @returns {string[]}
*/
function mergeFallbackIssueLabels(labels = []) {
const normalizedLabels = labels
.filter(label => !!label)
.map(label => String(label).trim())
.filter(label => label);
return [...new Set([MANAGED_FALLBACK_ISSUE_LABEL, ...normalizedLabels])];
}
/**
* Sanitizes configured assignees for fallback issue creation.
* Filters invalid values, removes the special "copilot" username (not a valid GitHub user
* for issue assignment), and enforces the MAX_ASSIGNEES limit.
* Returns null (no assignees field) if the sanitized list is empty.
* @param {string[]} assignees - Raw assignees from config
* @returns {string[] | null} Sanitized assignees or null if none remain
*/
function sanitizeFallbackAssignees(assignees) {
if (!assignees || assignees.length === 0) {
return null;
}
const sanitized = assignees
.filter(a => typeof a === "string")
.map(a => a.trim())
.filter(a => a.length > 0 && a.toLowerCase() !== "copilot");
if (sanitized.length === 0) {
return null;
}
const limitResult = tryEnforceArrayLimit(sanitized, MAX_ASSIGNEES, "assignees");
if (!limitResult.success) {
core.warning(`Assignees limit exceeded for fallback issue: ${limitResult.error}. Using first ${MAX_ASSIGNEES}.`);
return sanitized.slice(0, MAX_ASSIGNEES);
}
return sanitized;
}
/**
* Creates a fallback GitHub issue, retrying without assignees if the API rejects them.
* This ensures fallback issue creation remains reliable even if an assignee username
* is invalid or the repository does not have that collaborator.
* @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 }),
};
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 && assignees && assignees.length > 0) {
core.warning(`Fallback issue creation failed due to assignee error, retrying without assignees: ${getErrorMessage(error)}`);
const { assignees: _removed, ...payloadWithoutAssignees } = payload;
return await githubClient.rest.issues.create(payloadWithoutAssignees);
}
throw error;
}
}
/**
* Maximum limits for pull request parameters to prevent resource exhaustion.
* These limits align with GitHub's API constraints and security best practices.
*/
/** @type {number} Maximum number of files allowed per pull request */
const MAX_FILES = 100;
/**
* 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.
*
* @param {string} patchContent - Patch content to validate
* @throws {Error} When any limit is exceeded, with error code E003 and details
*/
function enforcePullRequestLimits(patchContent) {
if (!patchContent || !patchContent.trim()) {
return;
}
// Count files in patch by looking for "diff --git" lines
const fileMatches = patchContent.match(/^diff --git /gm);
const fileCount = fileMatches ? fileMatches.length : 0;
// Check file count - max limit exceeded check
if (fileCount > MAX_FILES) {
throw new Error(`E003: Cannot create pull request with more than ${MAX_FILES} files (received ${fileCount})`);
}
}
/**
* Generate a patch preview with max 500 lines and 2000 chars for issue body
* @param {string} patchContent - The full patch content
* @returns {string} Formatted patch preview
*/
function generatePatchPreview(patchContent) {
if (!patchContent || !patchContent.trim()) {
return "";
}
const lines = patchContent.split("\n");
const maxLines = 500;
const maxChars = 2000;
// Apply line limit first
let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n");
const lineTruncated = lines.length > maxLines;
// Apply character limit
const charTruncated = preview.length > maxChars;
if (charTruncated) {
preview = preview.slice(0, maxChars);
}
const truncated = lineTruncated || charTruncated;
const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`;
return `\n\n<details><summary>${summary}</summary>\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n</details>`;
}
/**
* 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 titlePrefix = config.title_prefix || "";
const envLabels = config.labels ? (Array.isArray(config.labels) ? config.labels : config.labels.split(",")).map(label => String(label).trim()).filter(label => label) : [];
const configReviewers = config.reviewers ? (Array.isArray(config.reviewers) ? config.reviewers : config.reviewers.split(",")).map(r => String(r).trim()).filter(r => r) : [];
const rawAssignees = config.assignees ? (Array.isArray(config.assignees) ? config.assignees : config.assignees.split(",")).map(a => String(a).trim()).filter(a => a) : [];
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 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 { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
const githubClient = await createAuthenticatedGitHubClient(config);
// 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 (envLabels.length > 0) {
core.info(`Default labels: ${envLabels.join(", ")}`);
}
if (configReviewers.length > 0) {
core.info(`Configured reviewers: ${configReviewers.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}`);
if (expiresHours > 0) {
core.info(`Pull requests expire after: ${expiresHours} hours`);
}
core.info(`Max count: ${maxCount}`);
core.info(`Max patch size: ${maxSizeKb} KB`);
// 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));
// 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
// Skip this check when a bundle file is present (bundle transport does not use a patch file)
const hasBundleFile = !!(bundleFilePath && fs.existsSync(bundleFilePath));
if (!hasBundleFile && (!patchFilePath || !fs.existsSync(patchFilePath))) {
// 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 = hasBundleFile ? false : true;
if (!hasBundleFile && patchFilePath && fs.existsSync(patchFilePath)) {
patchContent = fs.readFileSync(patchFilePath, "utf8");
isEmpty = !patchContent || !patchContent.trim();
}
// Enforce max limits on patch before processing
try {
enforcePullRequestLimits(patchContent);
} catch (error) {
const errorMessage = getErrorMessage(error);
core.warning(`Pull request limit exceeded: ${errorMessage}`);
return { success: false, error: errorMessage };
}
// 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";
// 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:** ⚠️ Patch file contains error\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 (patch error)");
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 };
}
}
}
// Validate patch size (unless empty)
if (!isEmpty) {
// maxSizeKb is already extracted from config at the top
const patchSizeBytes = Buffer.byteLength(patchContent, "utf8");
const patchSizeKb = Math.ceil(patchSizeBytes / 1024);
core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`);
if (patchSizeKb > maxSizeKb) {
const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`;
// If in staged mode, still show preview with error
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 size exceeded\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 (patch size error)");
return { success: true, staged: true };
}
return { success: false, error: message };
}
core.info("Patch size validation passed");
}
// Check file protection: allowlist (strict) or protected-files policy.
/** @type {string[] | null} Protected files that trigger fallback-to-issue handling */
let manifestProtectionFallback = null;
/** @type {unknown} */
let manifestProtectionPushFailedError = null;
if (!isEmpty) {
const protection = checkFileProtection(patchContent, config);
if (protection.action === "deny") {
const filesStr = protection.files.join(", ");
const message =
protection.source === "allowlist"
? `Cannot create pull request: patch modifies files outside the allowed-files list (${filesStr}). Add the files to the allowed-files configuration field or remove them from the patch.`
: `Cannot create pull request: patch modifies protected files (${filesStr}). Add them to the allowed-files configuration field or set protected-files: fallback-to-issue to create a review issue instead.`;
core.error(message);
return { success: false, error: message };
}
if (protection.action === "fallback") {
manifestProtectionFallback = protection.files;
core.warning(`Protected file protection triggered (fallback-to-issue): ${protection.files.join(", ")}. Will create review issue instead of pull request.`);
}
}
if (isEmpty && !isStaged && !allowEmpty) {
const message = "Patch file is empty - no changes to apply (noop operation)";
switch (ifNoChanges) {
case "error":
return { success: false, error: "No changes to push - failing as configured by if-no-changes: error" };
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 };
}
}
if (!isEmpty) {
core.info("Patch content validation passed");
} else if (allowEmpty) {
core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)");
} else {
core.info("Patch file is empty - processing noop operation");
}
// If in staged mode, emit step summary instead of creating PR
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 += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`;
summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`;
summaryContent += `**Base:** ${baseBranch}\n\n`;
if (pullRequestItem.body) {
summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`;
}
if (patchFilePath && fs.existsSync(patchFilePath)) {
const patchStats = fs.readFileSync(patchFilePath, "utf8");
if (patchStats.trim()) {
summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`;
summaryContent += `<details><summary>Show patch preview</summary>\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n</details>\n\n`;
} else {
summaryContent += `**Changes:** No changes (empty patch)\n\n`;
}
}
// Write to step summary
await core.summary.addRaw(summaryContent).write();
core.info("📝 Pull request creation preview written to step summary");
return { success: true, staged: true };
}
// Extract title, body, and branch from the JSON item
let title = pullRequestItem.title.trim();
let processedBody = pullRequestItem.body;
// Replace temporary ID references in the body with resolved issue/PR numbers
// This allows PRs to reference issues created earlier in the same workflow
// by using temporary IDs like #aw_123abc456def
if (resolvedTemporaryIds && Object.keys(resolvedTemporaryIds).length > 0) {
// Convert object to Map for compatibility with replaceTemporaryIdReferences
const tempIdMap = new Map(Object.entries(resolvedTemporaryIds));
processedBody = replaceTemporaryIdReferences(processedBody, tempIdMap, itemRepo);
core.info(`Resolved ${tempIdMap.size} temporary ID references in PR body`);
}
// Remove duplicate title from description if it starts with a header matching the title
processedBody = removeDuplicateTitleFromDescription(title, processedBody);
// Auto-add "Fixes #N" closing keyword if triggered from an issue and not already present.
// This ensures the triggering issue is auto-closed when the PR is merged.
// Agents are instructed to include this but don't reliably do so.
// This behavior can be disabled by setting auto-close-issue: false in the workflow config.
if (triggeringIssueNumber && autoCloseIssue) {
const hasClosingKeyword = /(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+#\d+/i.test(processedBody);
if (!hasClosingKeyword) {
processedBody = processedBody.trimEnd() + `\n\n- Fixes #${triggeringIssueNumber}`;
core.info(`Auto-added "Fixes #${triggeringIssueNumber}" closing keyword to PR body as bullet point`);
}
} else if (triggeringIssueNumber && !autoCloseIssue) {
core.info(`Skipping auto-close keyword for #${triggeringIssueNumber} (auto-close-issue: false)`);
}
let bodyLines = processedBody.split("\n");
let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null;
// Preserve the original agent branch name for bundle transport (the bundle was created
// using this branch name as the refs/heads ref inside the bundle file).
const originalAgentBranch = branchName;
const randomHex = crypto.randomBytes(8).toString("hex");
// SECURITY: Sanitize branch name to prevent shell injection (CWE-78)
// Branch names from user input must be normalized before use in git commands.
// When preserve-branch-name is disabled (default), a random salt suffix is
// appended to avoid collisions.
if (branchName) {
const originalBranchName = branchName;
branchName = normalizeBranchName(branchName, preserveBranchName ? null : randomHex);
// Validate it's not empty after normalization
if (!branchName) {
throw new Error(`Invalid branch name: sanitization resulted in empty string (original: "${originalBranchName}")`);
}
if (preserveBranchName) {
core.info(`Using branch name from JSONL without salt suffix (preserve-branch-name enabled): ${branchName}`);
} else {
core.info(`Using branch name from JSONL with added salt: ${branchName}`);
}
if (originalBranchName !== branchName) {
core.info(`Branch name sanitized: "${originalBranchName}" -> "${branchName}"`);
}
}
// If no title was found, use a default
if (!title) {
title = "Agent Output";
}
// Sanitize title for Unicode security and remove any duplicate prefixes
title = sanitizeTitle(title, titlePrefix);
// Apply title prefix (only if it doesn't already exist)
title = applyTitlePrefix(title, titlePrefix);
// Add AI disclaimer with workflow name and run url
const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
const workflowId = process.env.GH_AW_WORKFLOW_ID || "";
const runUrl = buildWorkflowRunUrl(context, context.repo);
const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE ?? "";
const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL ?? "";
const triggeringPRNumber = context.payload.pull_request?.number;
const triggeringDiscussionNumber = context.payload.discussion?.number;
// Add fingerprint comment if present
const trackerIDComment = getTrackerID("markdown");
if (trackerIDComment) {
bodyLines.push(trackerIDComment);
}
// Snapshot the body content (without footer) for use in protected-files fallback ordering.
// The protected-files section must appear before the footer (including guard notices such as
// the integrity-filtering note) so that the footer always comes last in the issue body.
const mainBodyContent = bodyLines.join("\n").trim();
// Generate footer using messages template system (respects custom messages.footer config)
// When footer is disabled, only add XML markers (no visible footer content)
const footerParts = [];
if (includeFooter) {
const historyUrl = generateHistoryUrl({
owner: repoParts.owner,
repo: repoParts.repo,
itemType: "pull_request",
workflowId,
serverUrl: context.serverUrl,
});
let footer = generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber, historyUrl).trimEnd();
footer = addExpirationToFooter(footer, expiresHours, "Pull Request");
if (expiresHours > 0) {
footer += "\n\n<!-- gh-aw-expires-type: pull-request -->";
}
bodyLines.push(``, ``, footer);
footerParts.push(footer);
}
// Add standalone workflow-id marker for searchability (consistent with comments)
// Always add XML markers even when footer is disabled
if (workflowId) {
const workflowIdMarker = generateWorkflowIdMarker(workflowId);
// Add to bodyLines for the normal PR body path.
// Add to footerParts so the fallback issue body places it after the protected-files section.
bodyLines.push(``, workflowIdMarker);
footerParts.push(workflowIdMarker);
}
bodyLines.push("");
// Prepare the body content
const body = bodyLines.join("\n").trim();
// Footer section (footer + workflow-id marker) used when ordering protected-files notices
const footerContent = footerParts.join("\n\n");
// Build labels array - merge config labels with message labels
let labels = [...envLabels];
if (pullRequestItem.labels && Array.isArray(pullRequestItem.labels)) {
labels = [...labels, ...pullRequestItem.labels];
}
labels = labels
.filter(label => !!label)
.map(label => String(label).trim())
.filter(label => label);
// Configuration enforces draft as a policy, not a fallback (consistent with autoMerge/allowEmpty)
const draft = draftDefault;
if (pullRequestItem.draft !== undefined && pullRequestItem.draft !== draftDefault) {
core.warning(
`Agent requested draft: ${pullRequestItem.draft}, but configuration enforces draft: ${draftDefault}. ` +
`Configuration takes precedence for security. To change this, update safe-outputs.create-pull-request.draft in the workflow file.`
);
}
core.info(`Creating pull request with title: ${title}`);
core.info(`Labels: ${JSON.stringify(labels)}`);
core.info(`Draft: ${draft}`);
core.info(`Body length: ${body.length}`);
// When no branch name was provided by the agent, generate a unique one.
if (!branchName) {
core.info("No branch name provided in JSONL, generating unique branch name");
branchName = `${workflowId}-${randomHex}`;
}
core.info(`Generated branch name: ${branchName}`);
core.info(`Base branch: ${baseBranch}`);
// Create a new branch using git CLI, ensuring it's based on the correct base branch
// First, fetch the base branch specifically (since we use shallow checkout)
core.info(`Fetching base branch: ${baseBranch}`);
// Fetch without creating/updating local branch to avoid conflicts with current branch
// This works even when we're already on the base branch
await exec.exec(`git fetch origin ${baseBranch}`);
// Apply the patch/bundle using git CLI (skip if empty)
// Track number of new commits pushed so we can restrict the extra empty commit
// to branches with exactly one new commit (security: prevents use of CI trigger
// token on multi-commit branches where workflow files may have been modified).
let newCommitCount = 0;
if (hasBundleFile) {
// Bundle transport: fetch commits directly from the bundle file.
// This preserves merge commit topology and per-commit metadata (messages, authorship)
// unlike git format-patch which flattens history and drops merge resolution content.
core.info(`Applying changes from bundle: ${bundleFilePath}`);
const bundleBranchRef = originalAgentBranch || branchName;
try {
// Fetch from bundle: creates a local branch pointing to the bundle's tip commit.
// The bundle contains refs/heads/<bundleBranchRef> which was the agent's working branch.
await exec.exec("git", ["fetch", bundleFilePath, `refs/heads/${bundleBranchRef}:refs/heads/${branchName}`]);
core.info(`Created local branch ${branchName} from bundle`);
await exec.exec("git", ["checkout", branchName]);
core.info(`Checked out branch ${branchName} from bundle`);
} catch (bundleError) {
core.error(`Failed to apply bundle: ${bundleError instanceof Error ? bundleError.message : String(bundleError)}`);
return { success: false, error: "Failed to apply bundle" };
}
// Push the commits from the bundle to the remote branch
try {
// Check if remote branch already exists (optional precheck)
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) {
core.warning(`Remote branch ${branchName} already exists - appending random suffix`);
const extraHex = crypto.randomBytes(4).toString("hex");
const oldBranch = branchName;
branchName = `${branchName}-${extraHex}`;
// Rename local branch
await exec.exec(`git branch -m ${oldBranch} ${branchName}`);
core.info(`Renamed branch to ${branchName}`);
}
await pushSignedCommits({
githubClient,
owner: repoParts.owner,
repo: repoParts.repo,
branch: branchName,
baseRef: `origin/${baseBranch}`,
cwd: process.cwd(),
});
core.info("Changes pushed to branch (from bundle)");
// Count new commits on PR branch relative to base
try {
const { stdout: countStr } = await exec.getExecOutput("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`]);
newCommitCount = parseInt(countStr.trim(), 10);
core.info(`${newCommitCount} new commit(s) on branch relative to origin/${baseBranch}`);
} catch {
core.info("Could not count new commits - extra empty commit will be skipped");
}
} catch (pushError) {
core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`);
if (!fallbackAsIssue) {
const error = `Failed to push changes: ${pushError instanceof Error ? pushError.message : String(pushError)}`;
return { success: false, error, error_type: "push_failed" };
}
core.warning("Git push operation failed - creating fallback issue instead of pull request");
const runUrl = buildWorkflowRunUrl(context, context.repo);
const runId = context.runId;
const artifactFileName = bundleFilePath ? bundleFilePath.replace("/tmp/gh-aw/", "") : "aw-unknown.bundle";
const fallbackBody = `${body}
---
> [!NOTE]
> This was originally intended as a pull request, but the git push operation failed.
>
> **Workflow Run:** [View run details and download bundle artifact](${runUrl})
>
> The bundle file is available in the \`agent\` artifact in the workflow run linked above.
To create a pull request with the changes:
\`\`\`sh
# Download the artifact from the workflow run
gh run download ${runId} -n agent -D /tmp/agent-${runId}
# Fetch the bundle into a local branch
git fetch /tmp/agent-${runId}/${artifactFileName} refs/heads/${bundleBranchRef}:refs/heads/${branchName}
git checkout ${branchName}
# Push the branch to origin
git push origin ${branchName}
# Create the pull request
gh pr create --title '${title}' --base ${baseBranch} --head ${branchName} --repo ${repoParts.owner}/${repoParts.repo}
\`\`\``;
try {
const { data: issue } = await createFallbackIssue(githubClient, repoParts, title, fallbackBody, mergeFallbackIssueLabels(labels), 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 error = `Failed to push changes and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`;
return { success: false, error };
}
}
} else {
// Checkout the base branch (using origin/${baseBranch} if local doesn't exist)
try {
await exec.exec(`git checkout ${baseBranch}`);
} catch (checkoutError) {
// If local branch doesn't exist, create it from origin
core.info(`Local branch ${baseBranch} doesn't exist, creating from origin/${baseBranch}`);
await exec.exec(`git checkout -b ${baseBranch} origin/${baseBranch}`);
}
// Handle branch creation/checkout
core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`);
await exec.exec(`git checkout -b ${branchName}`);
core.info(`Created new branch from base: ${branchName}`);
// Apply the patch using git CLI (skip if empty)
if (!isEmpty) {
core.info("Applying patch...");
const patchLines = patchContent.split("\n");
const previewLineCount = Math.min(500, patchLines.length);
core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`);
for (let i = 0; i < previewLineCount; i++) {
core.info(patchLines[i]);