-
Notifications
You must be signed in to change notification settings - Fork 448
Expand file tree
/
Copy pathsafe_output_handler_manager.cjs
More file actions
1392 lines (1248 loc) · 63.5 KB
/
Copy pathsafe_output_handler_manager.cjs
File metadata and controls
1392 lines (1248 loc) · 63.5 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" />
/**
* Safe Output Handler Manager
*
* This module manages the dispatch of safe output messages to dedicated handlers.
* It reads configuration, loads the appropriate handlers for enabled safe output types,
* and processes messages from the agent output file while maintaining a shared temporary ID map.
*/
const { loadAgentOutput } = require("./load_agent_output.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { ERR_CONFIG, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs");
const { hasUnresolvedTemporaryIds, replaceTemporaryIdReferences, replaceArtifactUrlReferences, normalizeTemporaryId } = require("./temporary_id.cjs");
const { generateMissingInfoSections } = require("./missing_info_formatter.cjs");
const { setCollectedMissings } = require("./missing_messages_helper.cjs");
const { writeSafeOutputSummaries } = require("./safe_output_summary.cjs");
const { getAssignToAgentAssigned, getAssignToAgentErrors, getAssignToAgentErrorCount, writeAssignToAgentSummary } = require("./assign_to_agent.cjs");
const { getCreateAgentSessionNumber, getCreateAgentSessionUrl, writeCreateAgentSessionSummary } = require("./create_agent_session.cjs");
const { createReviewBuffer } = require("./pr_review_buffer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { createManifestLogger, ensureManifestExists, extractCreatedItemFromResult, writeTemporaryIdMapFile } = require("./safe_output_manifest.cjs");
const { loadCustomSafeOutputJobTypes, loadCustomSafeOutputScriptHandlers, loadCustomSafeOutputActionHandlers, isStagedMode } = require("./safe_output_helpers.cjs");
const { emitSafeOutputActionOutputs } = require("./safe_outputs_action_outputs.cjs");
const { listCommentMemoryFiles, COMMENT_MEMORY_DIR } = require("./comment_memory_helpers.cjs");
const { expandFileReferences } = require("./runtime_import.cjs");
const nodePath = require("path");
const fs = require("fs");
/**
* Handler map configuration
* Maps safe output types to their handler module file paths
*/
const HANDLER_MAP = {
create_issue: "./create_issue.cjs",
add_comment: "./add_comment.cjs",
comment_memory: "./comment_memory.cjs",
create_discussion: "./create_discussion.cjs",
close_issue: "./close_issue.cjs",
close_discussion: "./close_discussion.cjs",
add_labels: "./add_labels.cjs",
remove_labels: "./remove_labels.cjs",
update_issue: "./update_issue.cjs",
update_discussion: "./update_discussion.cjs",
link_sub_issue: "./link_sub_issue.cjs",
update_release: "./update_release.cjs",
create_pull_request_review_comment: "./create_pr_review_comment.cjs",
submit_pull_request_review: "./submit_pr_review.cjs",
reply_to_pull_request_review_comment: "./reply_to_pr_review_comment.cjs",
resolve_pull_request_review_thread: "./resolve_pr_review_thread.cjs",
create_pull_request: "./create_pull_request.cjs",
push_to_pull_request_branch: "./push_to_pull_request_branch.cjs",
update_pull_request: "./update_pull_request.cjs",
merge_pull_request: "./merge_pull_request.cjs",
close_pull_request: "./close_pull_request.cjs",
mark_pull_request_as_ready_for_review: "./mark_pull_request_as_ready_for_review.cjs",
hide_comment: "./hide_comment.cjs",
set_issue_type: "./set_issue_type.cjs",
add_reviewer: "./add_reviewer.cjs",
assign_milestone: "./assign_milestone.cjs",
assign_to_user: "./assign_to_user.cjs",
unassign_from_user: "./unassign_from_user.cjs",
assign_to_agent: "./assign_to_agent.cjs",
create_agent_session: "./create_agent_session.cjs",
create_code_scanning_alert: "./create_code_scanning_alert.cjs",
autofix_code_scanning_alert: "./autofix_code_scanning_alert.cjs",
dispatch_workflow: "./dispatch_workflow.cjs",
dispatch_repository: "./dispatch_repository.cjs",
call_workflow: "./call_workflow.cjs",
create_missing_tool_issue: "./create_missing_tool_issue.cjs",
missing_tool: "./missing_tool.cjs",
create_missing_data_issue: "./create_missing_data_issue.cjs",
missing_data: "./missing_data.cjs",
noop: "./noop_handler.cjs",
report_incomplete: "./report_incomplete_handler.cjs",
create_report_incomplete_issue: "./create_report_incomplete_issue.cjs",
create_project: "./create_project.cjs",
create_project_status_update: "./create_project_status_update.cjs",
update_project: "./update_project.cjs",
upload_artifact: "./upload_artifact.cjs",
};
/**
* Message types handled by standalone steps (not through the handler manager)
* These types should not trigger warnings when skipped by the handler manager
*
* Standalone types: upload_asset, noop
* - Have dedicated processing steps with specialized logic
*/
const STANDALONE_STEP_TYPES = new Set(["upload_asset", "noop"]);
/**
* Code-push safe output types that must succeed before remaining outputs are processed.
* If any of these fail, the remaining non-code-push messages are cancelled with a clear reason.
*/
const CODE_PUSH_TYPES = new Set(["push_to_pull_request_branch", "create_pull_request"]);
function buildCommentMemoryMessagesFromFiles(existingMessages, config) {
if (!config.comment_memory) {
return [];
}
const fallbackMemoryId = normalizeCommentMemoryId(config?.comment_memory?.memory_id, "default");
const existingMemoryIds = new Set(existingMessages.filter(isCommentMemoryMessage).map(message => normalizeCommentMemoryId(message.memory_id, fallbackMemoryId)));
const fileEntries = listCommentMemoryFiles(COMMENT_MEMORY_DIR);
if (fileEntries.length === 0) {
return [];
}
const messages = [];
for (const entry of fileEntries) {
if (existingMemoryIds.has(entry.memoryId)) {
continue;
}
let body = "";
try {
body = fs.readFileSync(entry.filePath, "utf8").replace(/\n+$/, "");
} catch (error) {
core.warning(`Failed to read comment-memory file '${entry.filePath}': ${getErrorMessage(error)}`);
continue;
}
messages.push({
type: "comment_memory",
memory_id: entry.memoryId,
body,
});
}
if (messages.length > 0) {
core.info(`Loaded ${messages.length} comment_memory message(s) from ${COMMENT_MEMORY_DIR}`);
}
return messages;
}
function isCommentMemoryMessage(message) {
// memory_id normalization/validation is handled separately in normalizeCommentMemoryId.
return message?.type === "comment_memory";
}
function normalizeCommentMemoryId(memoryId, fallback = "default") {
if (typeof memoryId !== "string") {
return fallback;
}
const normalized = memoryId.trim();
return normalized.length > 0 ? normalized : fallback;
}
/**
* Load configuration for safe outputs
* Reads configuration from GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG environment variable
* @returns {Object} Safe outputs configuration
*/
function loadConfig() {
if (!process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG) {
throw new Error(`${ERR_CONFIG}: GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG environment variable is required but not set`);
}
try {
const config = JSON.parse(process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG);
core.info(`Loaded config from GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ${JSON.stringify(config)}`);
// Normalize config keys: convert hyphens to underscores
return Object.fromEntries(Object.entries(config).map(([k, v]) => [k.replace(/-/g, "_"), v]));
} catch (error) {
throw new Error(`${ERR_PARSE}: Failed to parse GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ${getErrorMessage(error)}`);
}
}
/** @type {Set<string>} Handler types that participate in the PR review buffer */
const PR_REVIEW_HANDLER_TYPES = new Set(["create_pull_request_review_comment", "submit_pull_request_review"]);
/**
* Load and initialize handlers for enabled safe output types
* Calls each handler's factory function (main) to get message processors
* @param {Object} config - Safe outputs configuration
* @param {Object} prReviewBuffer - Shared PR review buffer instance
* @returns {Promise<Map<string, Function>>} Map of type to message handler function
*/
async function loadHandlers(config, prReviewBuffer) {
const messageHandlers = new Map();
core.info("Loading and initializing safe output handlers based on configuration...");
for (const [type, handlerPath] of Object.entries(HANDLER_MAP)) {
// Check if this safe output type is enabled in the config
// The presence of the config key indicates the handler should be loaded
if (config[type]) {
try {
const handlerModule = require(handlerPath);
if (handlerModule && typeof handlerModule.main === "function") {
// Call the factory function with config to get the message handler
const handlerConfig = { ...(config[type] || {}) };
// Inject shared PR review buffer into handlers that need it
if (PR_REVIEW_HANDLER_TYPES.has(type)) {
handlerConfig._prReviewBuffer = prReviewBuffer;
}
const messageHandler = await handlerModule.main(handlerConfig);
if (typeof messageHandler !== "function") {
// This is a fatal error - the handler is misconfigured
// Re-throw to fail the step rather than continuing
const error = new Error(`Handler ${type} main() did not return a function - expected a message handler function but got ${typeof messageHandler}`);
core.error(`✗ Fatal error loading handler ${type}: ${error.message}`);
throw error;
}
messageHandlers.set(type, messageHandler);
core.info(`✓ Loaded and initialized handler for: ${type}`);
} else {
core.warning(`Handler module ${type} does not export a main function`);
}
} catch (error) {
// Re-throw fatal handler validation errors
const errorMessage = getErrorMessage(error);
if (errorMessage.includes("did not return a function")) {
throw error;
}
// For other errors (e.g., module not found), log warning and continue
core.warning(`Failed to load handler for ${type}: ${errorMessage}`);
}
} else {
core.debug(`Handler not enabled: ${type}`);
}
}
// Load custom script handlers from GH_AW_SAFE_OUTPUT_SCRIPTS
// These are inline scripts defined in safe-outputs.scripts that run in the handler loop
const customScriptHandlers = loadCustomSafeOutputScriptHandlers();
if (customScriptHandlers.size > 0) {
core.info(`Loading ${customScriptHandlers.size} custom script handler(s): ${[...customScriptHandlers.keys()].join(", ")}`);
const scriptBaseDir = nodePath.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "actions");
for (const [scriptType, scriptFilename] of customScriptHandlers) {
// Sanitize scriptFilename to prevent path traversal attacks: only the basename
// (no directory separators or ".." sequences) is allowed.
const safeFilename = nodePath.basename(scriptFilename);
if (safeFilename !== scriptFilename) {
core.error(`Invalid script filename for ${scriptType}: path traversal detected in "${scriptFilename}" — skipping`);
continue;
}
const scriptPath = nodePath.join(scriptBaseDir, safeFilename);
// Defense-in-depth: verify the resolved path remains within the expected directory.
// Use path.relative() to check containment robustly across all platforms.
const relativeToBase = nodePath.relative(scriptBaseDir, scriptPath);
if (relativeToBase.startsWith("..") || nodePath.isAbsolute(relativeToBase)) {
core.error(`Script path outside expected directory for ${scriptType}: "${scriptPath}" — skipping`);
continue;
}
try {
const scriptModule = require(scriptPath);
if (scriptModule && typeof scriptModule.main === "function") {
const handlerConfig = config[scriptType] || {};
const messageHandler = await scriptModule.main(handlerConfig);
if (typeof messageHandler !== "function") {
// Non-fatal: warn and skip this custom script handler rather than crashing the
// entire safe-output loop. A misconfigured user script should not block all
// other safe-output operations.
core.warning(`✗ Custom script handler ${scriptType} main() did not return a function (got ${typeof messageHandler}) — this handler will be skipped`);
} else {
messageHandlers.set(scriptType, messageHandler);
core.info(`✓ Loaded and initialized custom script handler for: ${scriptType}`);
}
} else {
core.warning(`Custom script handler module ${scriptType} does not export a main function — skipping`);
}
} catch (error) {
// Non-fatal: log a warning and continue loading the remaining handlers. A broken
// custom script should not prevent built-in or other custom handlers from running.
core.warning(`Failed to load custom script handler for ${scriptType}: ${getErrorMessage(error)} — this handler will be skipped`);
}
}
}
// Load custom action handlers from GH_AW_SAFE_OUTPUT_ACTIONS
// These are GitHub Actions configured in safe-outputs.actions. The handler applies
// temporary ID substitutions to the payload and exports `action_<name>_payload` outputs
// that compiler-generated `uses:` steps consume.
const customActionHandlers = loadCustomSafeOutputActionHandlers();
if (customActionHandlers.size > 0) {
core.info(`Loading ${customActionHandlers.size} custom action handler(s): ${[...customActionHandlers.keys()].join(", ")}`);
const actionHandlerPath = require("path").join(__dirname, "safe_output_action_handler.cjs");
for (const [actionType, actionName] of customActionHandlers) {
try {
const actionModule = require(actionHandlerPath);
if (actionModule && typeof actionModule.main === "function") {
const handlerConfig = { action_name: actionName, ...(config[actionType] || {}) };
const messageHandler = await actionModule.main(handlerConfig);
if (typeof messageHandler !== "function") {
core.warning(`✗ Custom action handler ${actionType} main() did not return a function (got ${typeof messageHandler}) — this handler will be skipped`);
} else {
messageHandlers.set(actionType, messageHandler);
core.info(`✓ Loaded and initialized custom action handler for: ${actionType}`);
}
} else {
core.warning(`Custom action handler module does not export a main function — skipping ${actionType}`);
}
} catch (error) {
core.warning(`Failed to load custom action handler for ${actionType}: ${getErrorMessage(error)} — this handler will be skipped`);
}
}
}
core.info(`Loaded ${messageHandlers.size} handler(s)`);
return messageHandlers;
}
/**
* Collect missing_tool, missing_data, noop, and report_incomplete messages from the messages array
* @param {Array<Object>} messages - Array of safe output messages
* @returns {{missingTools: Array<any>, missingData: Array<any>, noopMessages: Array<any>, reportIncomplete: Array<any>}} Object with collected missing items, noop messages, and incomplete signals
*/
function collectMissingMessages(messages) {
const missingTools = [];
const missingData = [];
const noopMessages = [];
const reportIncomplete = [];
for (const message of messages) {
if (message.type === "missing_tool") {
// Extract relevant fields from missing_tool message
if (message.tool && message.reason) {
missingTools.push({
tool: message.tool,
reason: message.reason,
alternatives: message.alternatives || null,
});
}
} else if (message.type === "missing_data") {
// Extract relevant fields from missing_data message
if (message.data_type && message.reason) {
missingData.push({
data_type: message.data_type,
reason: message.reason,
context: message.context || null,
alternatives: message.alternatives || null,
});
}
} else if (message.type === "noop") {
// Extract relevant fields from noop message
if (message.message) {
noopMessages.push({
message: message.message,
});
}
} else if (message.type === "report_incomplete") {
// Extract relevant fields from report_incomplete message
if (message.reason) {
reportIncomplete.push({
reason: message.reason,
details: message.details || null,
});
}
}
}
core.info(`Collected ${missingTools.length} missing tool(s), ${missingData.length} missing data item(s), ${noopMessages.length} noop message(s), and ${reportIncomplete.length} incomplete signal(s)`);
return { missingTools, missingData, noopMessages, reportIncomplete };
}
/**
* Format a log message for a manifest entry.
* Prefers the URL when available, otherwise falls back to the item number.
*
* @param {{type: string, url?: string, number?: number}} item - Manifest item
* @returns {string} Formatted log message
*/
function formatManifestLogMessage(item) {
if (item.url) return `📝 Manifest: logged ${item.type} → ${item.url}`;
if (item.number != null) return `📝 Manifest: logged ${item.type} #${item.number}`;
return `📝 Manifest: logged ${item.type}`;
}
/**
* Process all messages from agent output in the order they appear
* Dispatches each message to the appropriate handler while maintaining shared state (temporary ID map)
* Tracks outputs created with unresolved temporary IDs and generates synthetic updates after resolution
*
* @param {Map<string, Function>} messageHandlers - Map of message handler functions
* @param {Array<Object>} messages - Array of safe output messages
* @param {((item: {type: string, url?: string, number?: number, repo?: string, temporaryId?: string}) => void)|null} [onItemCreated] - Optional callback invoked after each successful create operation (for manifest logging)
* @returns {Promise<{success: boolean, results: Array<any>, temporaryIdMap: Object, artifactUrlMap: Map<string, string>, outputsWithUnresolvedIds: Array<any>, missings: Object, codePushFailures: Array<{type: string, error: string}>}>}
*/
async function processMessages(messageHandlers, messages, onItemCreated = null) {
const results = [];
// Collect missing_tool, missing_data, noop, and report_incomplete messages first
const missings = collectMissingMessages(messages);
// Initialize shared temporary ID map
// This will be populated by handlers as they create entities with temporary IDs
/** @type {Map<string, {repo: string, number: number}>} */
const temporaryIdMap = new Map();
// Track artifact URL mappings: normalised tmpId → artifact download URL.
// Populated after each successful upload_artifact call so that subsequent
// messages can have '#aw_ID' references replaced with the real artifact URL.
/** @type {Map<string, string>} */
const artifactUrlMap = new Map();
// Track outputs that were created with unresolved temporary IDs
// Format: {type, message, result, originalTempIdMapSize}
/** @type {Array<{type: string, message: any, result: any, originalTempIdMapSize: number}>} */
const outputsWithUnresolvedIds = [];
// Track messages that were deferred due to unresolved temporary IDs
// These will be retried after the first pass when more temp IDs may be resolved
/** @type {Array<{type: string, message: any, messageIndex: number, handler: Function}>} */
const deferredMessages = [];
// Track code-push failures for fail-fast behaviour.
// If a code-push type (push_to_pull_request_branch / create_pull_request) fails,
// all subsequent non-code-push messages are cancelled with a clear reason.
/** @type {Array<{type: string, error: string}>} */
const codePushFailures = [];
// Track when a code-push operation falls back to creating an issue or pull request instead.
// When set, subsequent add_comment messages will receive a correction note prepended
// to their body so the posted comment accurately reflects the actual fallback target.
/** @type {{type: string, fallbackTargetType: "issue" | "pull_request", number: number, url: string}|null} */
let codePushFallbackInfo = null;
// Load custom safe output job types (from GH_AW_SAFE_OUTPUT_JOBS env var)
// These are processed by dedicated custom job steps, not by this handler manager
const customJobTypes = loadCustomSafeOutputJobTypes();
if (customJobTypes.size > 0) {
core.info(`Loaded ${customJobTypes.size} custom safe output job type(s): ${[...customJobTypes].join(", ")}`);
}
core.info(`Processing ${messages.length} message(s) in order of appearance...`);
// Process messages in order of appearance
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const messageType = message.type;
if (!messageType) {
core.warning(`Skipping message ${i + 1} without type`);
continue;
}
// Fail-fast: if a previous code-push operation failed, cancel non-code-push messages.
// Exception: add_comment messages are allowed through so the status comment still reaches
// the user — they will be annotated with a failure note (see effectiveMessage logic below).
if (codePushFailures.length > 0 && !CODE_PUSH_TYPES.has(messageType) && messageType !== "add_comment") {
const cancelReason = `Cancelled: code push operation failed (${codePushFailures[0].type}: ${codePushFailures[0].error})`;
core.info(`⏭ Message ${i + 1} (${messageType}) cancelled — ${cancelReason}`);
results.push({
type: messageType,
messageIndex: i,
success: false,
cancelled: true,
reason: cancelReason,
});
continue;
}
const messageHandler = messageHandlers.get(messageType);
if (!messageHandler) {
// Check if this message type is handled by a standalone step
if (STANDALONE_STEP_TYPES.has(messageType)) {
// Silently skip - this is handled by a dedicated step
core.debug(`Message ${i + 1} (${messageType}) will be handled by standalone step`);
results.push({
type: messageType,
messageIndex: i,
success: false,
skipped: true,
reason: "Handled by standalone step",
});
continue;
}
// Check if this message type is handled by a custom safe output job
if (customJobTypes.has(messageType)) {
core.debug(`Message ${i + 1} (${messageType}) will be handled by custom safe output job`);
// Log the dispatch to the manifest so the operation is counted in SafeItemsCount.
// The custom job does the actual work; here we record the intent from the message.
if (onItemCreated) {
// Prefer item_number (explicit target), fall back to issue_number then pull_request_number.
// This mirrors the precedence order used by individual safe output handlers.
const rawNumber = message.item_number ?? message.issue_number ?? message.pull_request_number;
const itemNumber = rawNumber != null ? parseInt(String(rawNumber), 10) : undefined;
const validNumber = itemNumber != null && !isNaN(itemNumber) ? itemNumber : undefined;
const messageResult = {
...(validNumber != null ? { number: validNumber } : {}),
...(message.repo ? { repo: message.repo } : {}),
};
const createdItem = extractCreatedItemFromResult(messageType, messageResult);
if (createdItem) {
core.info(formatManifestLogMessage(createdItem));
onItemCreated(createdItem);
}
}
results.push({
type: messageType,
messageIndex: i,
success: false,
skipped: true,
reason: "Handled by custom safe output job",
});
continue;
}
// Unknown message type - warn the user
core.warning(
`⚠️ No handler loaded for message type '${messageType}' (message ${i + 1}/${messages.length}). The message will be skipped. This may happen if the safe output type is not configured in the workflow's safe-outputs section.`
);
results.push({
type: messageType,
messageIndex: i,
success: false,
error: `No handler loaded for type '${messageType}'`,
});
continue;
}
try {
core.info(`Processing message ${i + 1}/${messages.length}: ${messageType}`);
// Convert Map to plain object for handler
const resolvedTemporaryIds = Object.fromEntries(temporaryIdMap);
// Record the temp ID map size before processing to detect new IDs
const tempIdMapSizeBefore = temporaryIdMap.size;
// For add_comment messages: prepend any relevant correction notes before the AI-generated
// body so users see the clarification immediately.
let effectiveMessage = message;
if (messageType === "add_comment") {
// If a previous code-push operation fell back to a review issue, prepend a correction note
// so the posted comment accurately reflects the outcome.
if (codePushFallbackInfo) {
const fallbackNote =
codePushFallbackInfo.fallbackTargetType === "pull_request"
? `\n\n---\n> [!NOTE]\n> Direct push to the original pull request branch was not possible (diverged/non-fast-forward). A fallback pull request was created instead: [#${codePushFallbackInfo.number}](${codePushFallbackInfo.url})\n\n`
: `\n\n---\n> [!NOTE]\n> The pull request was not created — a fallback review issue was created instead due to protected file changes: [#${codePushFallbackInfo.number}](${codePushFallbackInfo.url})\n\n`;
effectiveMessage = { ...effectiveMessage, body: fallbackNote + (effectiveMessage.body || "") };
core.info(`Prepending fallback correction note to add_comment body (fallback ${codePushFallbackInfo.fallbackTargetType}: #${codePushFallbackInfo.number})`);
}
// If a previous code-push operation failed outright (e.g. patch application error),
// prepend a failure warning so the status comment accurately reflects that the
// code changes were not applied.
if (codePushFailures.length > 0) {
const failure = codePushFailures[0];
const failureNote = `\n\n---\n> [!WARNING]\n> The \`${failure.type}\` operation failed: ${failure.error}. The code changes were not applied.\n\n`;
effectiveMessage = { ...effectiveMessage, body: failureNote + (effectiveMessage.body || "") };
core.info(`Prepending code push failure note to add_comment body (${failure.type}: ${failure.error})`);
}
}
// Pre-process: replace any '#aw_ID' artifact URL references in the message body
// with the actual artifact URL so handlers receive the resolved URL directly.
// This is applied to all message types that carry a 'body' field.
if (artifactUrlMap.size > 0 && effectiveMessage.body && typeof effectiveMessage.body === "string") {
const resolvedBody = replaceArtifactUrlReferences(effectiveMessage.body, artifactUrlMap);
if (resolvedBody !== effectiveMessage.body) {
effectiveMessage = { ...effectiveMessage, body: resolvedBody };
core.info(`Resolved artifact URL reference(s) in ${messageType} body`);
}
}
// Pre-process: expand @/absolute/path file references in the message body.
// Agents may write a path like @/tmp/gh-aw/agent/comment-body.md as the body
// value; this resolves the file and inlines its contents before the handler runs.
// Only paths within GITHUB_WORKSPACE or /tmp/gh-aw are expanded; all others are ignored.
if (effectiveMessage.body && typeof effectiveMessage.body === "string") {
const workspaceDir = process.env.GITHUB_WORKSPACE || "";
const expandedBody = expandFileReferences(effectiveMessage.body, workspaceDir);
if (expandedBody !== effectiveMessage.body) {
effectiveMessage = { ...effectiveMessage, body: expandedBody };
core.info(`Expanded @filepath reference(s) in ${messageType} body`);
}
}
// Call the message handler with the individual message and resolved temp IDs
const result = await messageHandler(effectiveMessage, resolvedTemporaryIds, temporaryIdMap);
// Check if the handler explicitly returned a skipped result (e.g. if_no_changes: warn/ignore).
// Skipped results should NOT trigger fail-fast cancellation of subsequent messages.
if (result && result.success === false && result.skipped === true && !result.deferred) {
const msg = result.error || "Handler returned success: false with skipped: true";
core.info(`⏭ Message ${i + 1} (${messageType}) skipped — ${msg}`);
results.push({
type: messageType,
messageIndex: i,
success: false,
skipped: true,
error: msg,
});
continue;
}
// Check if the handler explicitly returned a failure
if (result && result.success === false && !result.deferred) {
const errorMsg = result.error || "Handler returned success: false";
core.error(`✗ Message ${i + 1} (${messageType}) failed: ${errorMsg}`);
results.push({
type: messageType,
messageIndex: i,
success: false,
error: errorMsg,
});
// Track code-push failures for fail-fast behaviour
if (CODE_PUSH_TYPES.has(messageType)) {
codePushFailures.push({ type: messageType, error: errorMsg });
core.warning(`⚠️ Code push operation '${messageType}' failed — remaining safe outputs will be cancelled`);
}
continue;
}
// Check if the operation was deferred due to unresolved temporary IDs
if (result && result.deferred === true) {
core.info(`⏸ Message ${i + 1} (${messageType}) deferred - will retry after first pass`);
deferredMessages.push({
type: messageType,
message: effectiveMessage,
messageIndex: i,
handler: messageHandler,
});
results.push({
type: messageType,
messageIndex: i,
success: false,
deferred: true,
result,
});
continue;
}
// If handler returned a temp ID mapping, add it to our map
if (result && result.temporaryId && result.repo && result.number) {
const normalizedTempId = normalizeTemporaryId(result.temporaryId);
temporaryIdMap.set(normalizedTempId, {
repo: result.repo,
number: result.number,
});
core.info(`Registered temporary ID: ${result.temporaryId} -> ${result.repo}#${result.number}`);
}
// If this was a successful upload_artifact, register the artifact URL so that
// subsequent messages can have '#aw_ID' references replaced with the real URL.
// upload_artifact returns { tmpId, artifactUrl } (not temporaryId/repo/number).
if (messageType === "upload_artifact" && result && result.tmpId && result.artifactUrl) {
const normalizedTmpId = normalizeTemporaryId(result.tmpId);
if (!artifactUrlMap.has(normalizedTmpId)) {
artifactUrlMap.set(normalizedTmpId, result.artifactUrl);
core.info(`Registered artifact URL for temporary ID: ${result.tmpId}`);
} else {
core.warning(`Duplicate artifact temporary ID '${result.tmpId}' encountered; keeping the first registered URL and ignoring the later upload.`);
}
}
// Track when a code-push operation falls back to an issue or pull request so subsequent
// add_comment messages can include a correction note.
if (CODE_PUSH_TYPES.has(messageType) && result && result.fallback_used === true) {
if (result.issue_number != null && result.issue_url) {
codePushFallbackInfo = {
type: messageType,
fallbackTargetType: "issue",
number: result.issue_number,
url: result.issue_url,
};
core.info(`Code push '${messageType}' fell back to review issue #${result.issue_number} — add_comment messages will be annotated`);
} else if (result.pull_request_number != null && result.pull_request_url) {
codePushFallbackInfo = {
type: messageType,
fallbackTargetType: "pull_request",
number: result.pull_request_number,
url: result.pull_request_url,
};
core.info(`Code push '${messageType}' fell back to pull request #${result.pull_request_number} — add_comment messages will be annotated`);
}
}
// Check if this output was created with unresolved temporary IDs
// For create_issue, create_discussion, add_comment - check if body has unresolved IDs
// Handle add_comment which returns an array of comments
if (messageType === "add_comment" && Array.isArray(result)) {
const contentToCheck = getContentToCheck(messageType, message, result);
if (contentToCheck && hasUnresolvedTemporaryIds(contentToCheck, temporaryIdMap, artifactUrlMap)) {
// Track each comment that was created with unresolved temp IDs
for (const comment of result) {
if (comment._tracking) {
core.info(`Comment ${comment._tracking.commentId} on ${comment._tracking.repo}#${comment._tracking.itemNumber} was created with unresolved temporary IDs - tracking for update`);
outputsWithUnresolvedIds.push({
type: messageType,
message: message,
result: {
commentId: comment._tracking.commentId,
itemNumber: comment._tracking.itemNumber,
repo: comment._tracking.repo,
isDiscussion: comment._tracking.isDiscussion,
},
originalTempIdMapSize: tempIdMapSizeBefore,
});
}
}
}
} else if (result && result.number && result.repo) {
// Handle create_issue, create_discussion
const contentToCheck = getContentToCheck(messageType, message, result);
if (contentToCheck && hasUnresolvedTemporaryIds(contentToCheck, temporaryIdMap, artifactUrlMap)) {
core.info(`Output ${result.repo}#${result.number} was created with unresolved temporary IDs - tracking for update`);
outputsWithUnresolvedIds.push({
type: messageType,
message: message,
result: result,
originalTempIdMapSize: tempIdMapSizeBefore,
});
}
}
results.push({
type: messageType,
messageIndex: i,
success: true,
result,
});
// Log to manifest if this was a create operation
if (onItemCreated) {
if (Array.isArray(result)) {
for (const item of result) {
const createdItem = extractCreatedItemFromResult(messageType, item);
if (createdItem) {
core.info(formatManifestLogMessage(createdItem));
onItemCreated(createdItem);
}
}
} else {
const createdItem = extractCreatedItemFromResult(messageType, result);
if (createdItem) {
core.info(formatManifestLogMessage(createdItem));
onItemCreated(createdItem);
}
}
}
core.info(`✓ Message ${i + 1} (${messageType}) completed successfully`);
} catch (error) {
core.error(`✗ Message ${i + 1} (${messageType}) failed: ${getErrorMessage(error)}`);
results.push({
type: messageType,
messageIndex: i,
success: false,
error: getErrorMessage(error),
});
// Track code-push failures for fail-fast behaviour
if (CODE_PUSH_TYPES.has(messageType)) {
codePushFailures.push({ type: messageType, error: getErrorMessage(error) });
core.warning(`⚠️ Code push operation '${messageType}' failed — remaining safe outputs will be cancelled`);
}
}
}
// Retry deferred messages now that more temporary IDs may have been resolved
// This retry loop mirrors the main processing loop but operates on messages that were
// deferred during the first pass (e.g., link_sub_issue waiting for parent/sub creation).
// IMPORTANT: Like the main loop, this must register temporary IDs and track outputs
// with unresolved IDs to enable full synthetic update resolution.
if (deferredMessages.length > 0) {
core.info(`\n=== Retrying Deferred Messages ===`);
core.info(`Found ${deferredMessages.length} deferred message(s) to retry`);
for (const deferred of deferredMessages) {
try {
core.info(`Retrying message ${deferred.messageIndex + 1}/${messages.length}: ${deferred.type}`);
// Convert Map to plain object for handler
const resolvedTemporaryIds = Object.fromEntries(temporaryIdMap);
// Record the temp ID map size before processing to detect new IDs
const tempIdMapSizeBefore = temporaryIdMap.size;
// Call the handler again with updated temp ID map
const result = await deferred.handler(deferred.message, resolvedTemporaryIds, temporaryIdMap);
// Check if the handler explicitly returned a failure
if (result && result.success === false && !result.deferred) {
const errorMsg = result.error || "Handler returned success: false";
core.error(`✗ Retry of message ${deferred.messageIndex + 1} (${deferred.type}) failed: ${errorMsg}`);
// Update the result to error
const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex);
if (resultIndex >= 0) {
results[resultIndex].success = false;
results[resultIndex].error = errorMsg;
}
continue;
}
// Check if still deferred
if (result && result.deferred === true) {
core.warning(`⏸ Message ${deferred.messageIndex + 1} (${deferred.type}) still deferred - some temporary IDs remain unresolved`);
// Update the existing result entry
const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex);
if (resultIndex >= 0) {
results[resultIndex].result = result;
}
} else {
core.info(`✓ Message ${deferred.messageIndex + 1} (${deferred.type}) completed on retry`);
// If handler returned a temp ID mapping, add it to our map
// This ensures that sub-issues created during deferred retry have their temporary IDs
// registered so parent issues can reference them in synthetic updates
if (result && result.temporaryId && result.repo && result.number) {
const normalizedTempId = normalizeTemporaryId(result.temporaryId);
temporaryIdMap.set(normalizedTempId, {
repo: result.repo,
number: result.number,
});
core.info(`Registered temporary ID: ${result.temporaryId} -> ${result.repo}#${result.number}`);
}
// Check if this output was created with unresolved temporary IDs
// For create_issue, create_discussion - check if body has unresolved IDs
// This enables synthetic updates to resolve references after all items are created
if (result && result.number && result.repo) {
const contentToCheck = getContentToCheck(deferred.type, deferred.message, result);
if (contentToCheck && hasUnresolvedTemporaryIds(contentToCheck, temporaryIdMap, artifactUrlMap)) {
core.info(`Output ${result.repo}#${result.number} was created with unresolved temporary IDs - tracking for update`);
outputsWithUnresolvedIds.push({
type: deferred.type,
message: deferred.message,
result: result,
originalTempIdMapSize: tempIdMapSizeBefore,
});
}
}
// Update the result to success
const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex);
if (resultIndex >= 0) {
results[resultIndex].success = true;
results[resultIndex].deferred = false;
results[resultIndex].result = result;
}
// Log to manifest after deferred retry success
if (onItemCreated) {
const createdItem = extractCreatedItemFromResult(deferred.type, result);
if (createdItem) {
core.info(formatManifestLogMessage(createdItem));
onItemCreated(createdItem);
}
}
}
} catch (error) {
core.error(`✗ Retry of message ${deferred.messageIndex + 1} (${deferred.type}) failed: ${getErrorMessage(error)}`);
// Update the result to error
const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex);
if (resultIndex >= 0) {
results[resultIndex].error = getErrorMessage(error);
}
}
}
}
// Return outputs with unresolved IDs for synthetic update processing
// Convert temporaryIdMap to plain object for serialization
const temporaryIdMapObj = Object.fromEntries(temporaryIdMap);
return {
success: true,
results,
temporaryIdMap: temporaryIdMapObj,
artifactUrlMap,
outputsWithUnresolvedIds,
missings,
codePushFailures,
};
}
/**
* Get the content field to check for unresolved temporary IDs based on message type
* @param {string} messageType - Type of the message
* @param {any} message - The message object
* @param {any} [result] - Handler result (used for transformed/managed bodies)
* For comment_memory, handlers return a managedBody that includes XML wrapper/footer;
* this differs from message.body and must be used for temporary ID detection.
* @returns {string|null} Content to check for temporary IDs
*/
function getContentToCheck(messageType, message, result) {
switch (messageType) {
case "create_issue":
return message.body || "";
case "create_discussion":
return message.body || "";
case "add_comment":
return message.body || "";
case "comment_memory":
return result?.managedBody || message.body || "";
default:
return null;
}
}
/**
* Update the body of an issue with resolved temporary IDs
* @param {any} github - GitHub API client
* @param {any} context - GitHub Actions context
* @param {string} repo - Repository in "owner/repo" format
* @param {number} issueNumber - Issue number to update
* @param {string} updatedBody - Updated body content with resolved temp IDs
* @returns {Promise<void>}
*/
async function updateIssueBody(github, context, repo, issueNumber, updatedBody) {
const [owner, repoName] = repo.split("/");
core.info(`Updating issue ${repo}#${issueNumber} body with resolved temporary IDs`);
await github.rest.issues.update({
owner,
repo: repoName,
issue_number: issueNumber,
body: sanitizeContent(updatedBody),
});
core.info(`✓ Updated issue ${repo}#${issueNumber}`);
}
/**
* Update the body of a discussion with resolved temporary IDs
* @param {any} github - GitHub API client
* @param {any} context - GitHub Actions context
* @param {string} repo - Repository in "owner/repo" format
* @param {number} discussionNumber - Discussion number to update
* @param {string} updatedBody - Updated body content with resolved temp IDs
* @returns {Promise<void>}
*/
async function updateDiscussionBody(github, context, repo, discussionNumber, updatedBody) {
const [owner, repoName] = repo.split("/");
core.info(`Updating discussion ${repo}#${discussionNumber} body with resolved temporary IDs`);
// Get the discussion node ID first
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
discussion(number: $number) {
id
}
}
}
`;
const result = await github.graphql(query, {
owner,
repo: repoName,
number: discussionNumber,
});
const discussionId = result.repository.discussion.id;
// Update the discussion body using GraphQL mutation
const mutation = `
mutation($discussionId: ID!, $body: String!) {
updateDiscussion(input: {discussionId: $discussionId, body: $body}) {
discussion {
id
number
}
}
}
`;
await github.graphql(mutation, {
discussionId,
body: sanitizeContent(updatedBody),
});
core.info(`✓ Updated discussion ${repo}#${discussionNumber}`);
}
/**
* Update the body of a comment with resolved temporary IDs
* @param {any} github - GitHub API client
* @param {any} context - GitHub Actions context
* @param {string} repo - Repository in "owner/repo" format
* @param {number} commentId - Comment ID to update
* @param {string} updatedBody - Updated body content with resolved temp IDs
* @param {boolean} isDiscussion - Whether this is a discussion comment
* @returns {Promise<void>}
*/
async function updateCommentBody(github, context, repo, commentId, updatedBody, isDiscussion = false) {
const [owner, repoName] = repo.split("/");
core.info(`Updating comment ${commentId} body with resolved temporary IDs`);
const sanitizedBody = sanitizeContent(updatedBody);
if (isDiscussion) {
// For discussion comments, we need to use GraphQL
// Get the comment node ID first