-
Notifications
You must be signed in to change notification settings - Fork 448
Expand file tree
/
Copy pathupdate_handler_factory.cjs
More file actions
315 lines (276 loc) · 13.8 KB
/
Copy pathupdate_handler_factory.cjs
File metadata and controls
315 lines (276 loc) · 13.8 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
// @ts-check
/// <reference types="@actions/github-script" />
/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
*/
const { getErrorMessage } = require("./error_helpers.cjs");
const { resolveTarget, isStagedMode } = require("./safe_output_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { attachExecutionState } = require("./safe_output_execution_metadata.cjs");
const { withRetry, isTransientError } = require("./error_recovery.cjs");
const { loadTemporaryIdMapFromResolved, resolveRepoIssueTarget } = require("./temporary_id.cjs");
/**
* @typedef {Object} UpdateHandlerConfig
* @property {string} itemType - Type of item (e.g., "update_issue", "update_pull_request", "update_discussion")
* @property {string} itemTypeName - Human-readable name (e.g., "issue", "pull request", "discussion")
* @property {boolean} supportsPR - Whether this handler supports PR context
* @property {Function} resolveItemNumber - Function to resolve item number from message
* @property {Function} buildUpdateData - Function to build update data from message
* @property {Function} executeUpdate - Function to execute the update API call
* @property {Function} formatSuccessResult - Function to format success result
* @property {Object} [additionalConfig] - Additional configuration options specific to the handler
* @property {Function} [itemFilter] - Optional async filter function called before update; returns null to proceed or a result object to skip
* @property {{captureBefore?: Function, captureAfter?: Function}} [captureExecutionMetadata] - Optional execution metadata hooks
*/
/**
* Creates a standard resolve number function for issue/PR handlers that use resolveTarget helper
* This factory eliminates duplication between update_issue and update_pull_request
*
* @param {Object} config - Configuration for the resolve function
* @param {string} config.itemType - Type of item (e.g., "update_issue", "update_pull_request")
* @param {string} config.itemNumberField - Field name in item object (e.g., "issue_number", "pull_request_number")
* @param {boolean} config.supportsPR - Whether this handler supports PR context
* @param {boolean} config.supportsIssue - Whether this handler supports issue context
* @returns {Function} Resolve number function
*/
function createStandardResolveNumber(config) {
const { itemType, itemNumberField, supportsPR, supportsIssue } = config;
return function resolveNumber(item, updateTarget, context, resolvedTemporaryIds) {
// Resolve temporary IDs in the item number field before target resolution
let resolvedItem = item;
const itemNumberValue = item[itemNumberField];
if (resolvedTemporaryIds && itemNumberValue != null) {
const tempIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds);
const resolvedTarget = resolveRepoIssueTarget(itemNumberValue, tempIdMap, context.repo.owner, context.repo.repo);
if (resolvedTarget.wasTemporaryId && resolvedTarget.resolved) {
resolvedItem = { ...item, [itemNumberField]: resolvedTarget.resolved.number };
core.info(`Resolved temporary ID '${itemNumberValue}' to #${resolvedTarget.resolved.number}`);
} else if (resolvedTarget.wasTemporaryId && !resolvedTarget.resolved) {
return {
success: false,
deferred: true,
error: resolvedTarget.errorMessage || `Unresolved temporary ID: ${itemNumberValue}`,
};
}
}
const targetResult = resolveTarget({
targetConfig: updateTarget,
item: { ...resolvedItem, item_number: resolvedItem[itemNumberField] },
context: context,
itemType: itemType,
supportsPR: supportsPR,
supportsIssue: supportsIssue,
});
if (!targetResult.success) {
return { success: false, error: targetResult.error };
}
return { success: true, number: targetResult.number };
};
}
/**
* Creates a standard format success result function
* This factory eliminates duplication across all update handlers
*
* @param {Object} fieldMapping - Mapping of result fields
* @param {string} fieldMapping.numberField - Field name for number (e.g., "number", "pull_request_number")
* @param {string} fieldMapping.urlField - Field name for URL (e.g., "url", "pull_request_url")
* @param {string} fieldMapping.urlSource - Source field in updated item (e.g., "html_url", "url")
* @returns {Function} Format success result function
*/
function createStandardFormatResult(fieldMapping) {
const { numberField, urlField, urlSource } = fieldMapping;
return function formatSuccessResult(itemNumber, updatedItem) {
const result = {
success: true,
[numberField]: itemNumber,
[urlField]: updatedItem[urlSource],
title: updatedItem.title,
};
return result;
};
}
/**
* Creates a handler factory function with common update logic
* This factory encapsulates the shared control flow:
* - Configuration defaults (target, max count)
* - Max count enforcement
* - Target resolution
* - Empty update validation
* - Success/error response shaping
*
* @param {UpdateHandlerConfig} handlerConfig - Configuration for the specific update handler
* @returns {HandlerFactoryFunction} Handler factory function
*/
function createUpdateHandlerFactory(handlerConfig) {
const { itemType, itemTypeName, supportsPR, resolveItemNumber, buildUpdateData, executeUpdate, formatSuccessResult, additionalConfig = {}, itemFilter = null, captureExecutionMetadata = null } = handlerConfig;
/**
* Main handler factory
* @type {HandlerFactoryFunction}
*/
return async function main(config = {}) {
// Extract configuration with defaults
const updateTarget = config.target || "triggering";
const maxCount = config.max || 10;
// Create an authenticated GitHub client. Uses config["github-token"] when set
// (for cross-repository operations), otherwise falls back to the step-level github.
const githubClient = await createAuthenticatedGitHubClient(config);
// Resolve default target repo and allowed repos for cross-repository routing.
// If no target-repo is configured, defaults to the current repository.
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
// Check if we're in staged mode
const isStaged = isStagedMode(config);
const configParts = [
`max=${maxCount}`,
`target=${updateTarget}`,
...Object.entries(additionalConfig)
.filter(([key]) => config[key] !== undefined)
.map(([key]) => `${key}=${config[key]}`),
];
core.info(`Update ${itemTypeName} configuration: ${configParts.join(", ")}`);
// Track state
let processedCount = 0;
/**
* Message handler function
* @param {Object} message - The update message
* @param {Object} resolvedTemporaryIds - Resolved temporary IDs
* @returns {Promise<Object>} Result
*/
return async function handleUpdate(message, resolvedTemporaryIds) {
// Check max limit
if (processedCount >= maxCount) {
core.warning(`Skipping ${itemType}: max count of ${maxCount} reached`);
return {
success: false,
error: `Max count of ${maxCount} reached`,
};
}
processedCount++;
const item = message;
// Resolve cross-repo target: always validate the target repository against the
// allowed repos and use it as the effective context. When item.repo is set it
// overrides the default; otherwise defaultTargetRepo is used. This mirrors the
// add_comment routing behaviour and ensures target-repo config is honoured even
// when the agent does not explicitly provide a repo field.
// Using {any} type to allow partial context override (effectiveContext.repo may differ from context.repo).
const repoResult = resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, itemTypeName);
if (!repoResult.success) {
core.warning(repoResult.error);
return { success: false, error: repoResult.error };
}
/** @type {any} */
const effectiveContext = { ...context, repo: repoResult.repoParts };
// Log cross-repo routing when the agent explicitly set a repo field,
// or when the resolved repo differs from the current workflow's repository.
const workflowRepo = `${context.repo.owner}/${context.repo.repo}`;
if (item.repo || repoResult.repo !== workflowRepo) {
core.info(`Cross-repo update: targeting ${repoResult.repo}`);
}
// Resolve item number (may use custom logic)
const itemNumberResult = resolveItemNumber(item, updateTarget, effectiveContext, resolvedTemporaryIds);
if (!itemNumberResult.success) {
core.warning(itemNumberResult.error);
return {
success: false,
deferred: itemNumberResult.deferred || false,
error: itemNumberResult.error,
};
}
const itemNumber = itemNumberResult.number;
core.info(`Resolved target ${itemTypeName} #${itemNumber} (target config: ${updateTarget})`);
// Apply required-labels/required-title-prefix filter if configured
if (itemFilter) {
const filterResult = await itemFilter(githubClient, repoResult.repoParts, itemNumber, config);
if (filterResult) {
return filterResult;
}
}
// Build update data (handler-specific logic)
const updateDataResult = buildUpdateData(item, config);
if (!updateDataResult.success) {
core.warning(updateDataResult.error);
return {
success: false,
error: updateDataResult.error,
};
}
// Check if buildUpdateData returned a skipped result (for update_pull_request)
if (updateDataResult.skipped) {
core.info(`No update fields provided for ${itemTypeName} #${itemNumber} - treating as no-op (skipping update)`);
return {
success: true,
skipped: true,
reason: updateDataResult.reason,
};
}
const updateData = updateDataResult.data;
// Store the original workflow repo in updateData so that executeUpdate functions
// can build correct attribution URLs. effectiveContext.repo may be overridden to
// the cross-repo target, but the run URL must always reference the current workflow.
updateData._workflowRepo = context.repo;
// Validate that we have something to update
// Note: Fields starting with "_" are internal (e.g., _rawBody, _operation)
// and will be processed by executeUpdate. We should NOT skip if _rawBody exists.
const updateFields = Object.keys(updateData).filter(k => !k.startsWith("_"));
const hasRawBody = updateData._rawBody !== undefined;
// Sanitize body content before forwarding to the GitHub API (safe outputs conformance)
if (hasRawBody) {
updateData._rawBody = sanitizeContent(updateData._rawBody);
}
if (updateFields.length === 0 && !hasRawBody) {
core.info(`No update fields provided for ${itemTypeName} #${itemNumber} - treating as no-op (skipping update)`);
return {
success: true,
skipped: true,
reason: "No update fields provided",
};
}
// Include "body" in logged fields when a body update is queued (stored as internal _rawBody)
const logFields = hasRawBody ? [...updateFields, "body"] : updateFields;
core.info(`Updating ${itemTypeName} #${itemNumber} in ${effectiveContext.repo.owner}/${effectiveContext.repo.repo} with: ${JSON.stringify(logFields)}`);
// If in staged mode, preview the update without applying it
if (isStaged) {
logStagedPreviewInfo(`Would update ${itemTypeName} #${itemNumber} with fields: ${JSON.stringify(logFields)}`);
return {
success: true,
staged: true,
previewInfo: {
number: itemNumber,
updateFields,
hasRawBody,
},
};
}
// Execute the update using the authenticated client and effective context.
// githubClient uses config["github-token"] when set (for cross-repo), otherwise global github.
// effectiveContext.repo contains the target repo owner/name for cross-repo routing.
// Retry on transient errors (e.g. GitHub API returning HTML instead of JSON on 500 crashes).
try {
const beforeState = captureExecutionMetadata?.captureBefore ? await captureExecutionMetadata.captureBefore(githubClient, effectiveContext, itemNumber, updateData) : null;
const updatedItem = await withRetry(() => executeUpdate(githubClient, effectiveContext, itemNumber, updateData), { maxRetries: 1, initialDelayMs: 2000, shouldRetry: isTransientError }, `update ${itemTypeName} #${itemNumber}`);
core.info(`Successfully updated ${itemTypeName} #${itemNumber}: ${updatedItem.html_url || updatedItem.url}`);
// Format and return success result
const result = {
...formatSuccessResult(itemNumber, updatedItem),
repo: `${effectiveContext.repo.owner}/${effectiveContext.repo.repo}`,
};
const afterState = captureExecutionMetadata?.captureAfter ? await captureExecutionMetadata.captureAfter(updatedItem, beforeState, updateData) : null;
return attachExecutionState(result, beforeState, afterState);
} catch (error) {
const errorMessage = getErrorMessage(error);
core.error(`Failed to update ${itemTypeName} #${itemNumber}: ${errorMessage}`);
return {
success: false,
error: errorMessage,
};
}
};
};
}
module.exports = {
createUpdateHandlerFactory,
createStandardResolveNumber,
createStandardFormatResult,
};