From 95d5dba40b458d0b250617a3134ac9ec174781aa Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 12 Apr 2026 13:16:52 +0000
Subject: [PATCH 1/4] Initial plan


From 5a6743d9fd502ce18619a0f7ad2fcaef37289824 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 12 Apr 2026 13:29:48 +0000
Subject: [PATCH 2/4] refactor: extract shared count-gated handler scaffold for
 safe-output handlers

Introduce `handler_scaffold.cjs` with `createCountGatedHandler()` that
centralizes the duplicated max-count gating, processedCount state, and
standardized error envelope across safe-output handlers.

Migrate first two cohorts:
- add_labels, remove_labels (label handlers)
- assign_to_user, unassign_from_user (assignee handlers)

All 100 existing + new tests pass. No behavior changes.

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/6dc05674-5070-4ae9-870b-9abb73c43831

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 actions/setup/js/add_labels.cjs            | 300 +++++++++----------
 actions/setup/js/assign_to_user.cjs        | 273 ++++++++---------
 actions/setup/js/handler_scaffold.cjs      |  54 ++++
 actions/setup/js/handler_scaffold.test.cjs | 154 ++++++++++
 actions/setup/js/remove_labels.cjs         | 331 ++++++++++-----------
 actions/setup/js/unassign_from_user.cjs    | 245 ++++++++-------
 6 files changed, 761 insertions(+), 596 deletions(-)
 create mode 100644 actions/setup/js/handler_scaffold.cjs
 create mode 100644 actions/setup/js/handler_scaffold.test.cjs

diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs
index 27695b7572d..ea3c49e19e3 100644
--- a/actions/setup/js/add_labels.cjs
+++ b/actions/setup/js/add_labels.cjs
@@ -23,183 +23,173 @@ const { isStagedMode } = require("./safe_output_helpers.cjs");
 const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
 const { resolveRepoIssueTarget, loadTemporaryIdMapFromResolved } = require("./temporary_id.cjs");
 const { MAX_LABELS } = require("./constants.cjs");
+const { createCountGatedHandler } = require("./handler_scaffold.cjs");
 
 /**
  * Main handler factory for add_labels
- * Returns a message handler function that processes individual add_labels messages
+ * Uses shared count-gated scaffold for max-limit enforcement.
  * @type {HandlerFactoryFunction}
  */
-async function main(config = {}) {
-  const { allowed: allowedLabels = [], blocked: blockedPatterns = [] } = config;
-  const maxCount = config.max ?? 10;
-  const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
-  const githubClient = await createAuthenticatedGitHubClient(config);
-  const isStaged = isStagedMode(config);
-
-  core.info(`Add labels configuration: max=${maxCount}`);
-  if (allowedLabels.length > 0) core.info(`Allowed labels: ${allowedLabels.join(", ")}`);
-  if (blockedPatterns.length > 0) core.info(`Blocked patterns: ${blockedPatterns.join(", ")}`);
-  core.info(`Default target repo: ${defaultTargetRepo}`);
-  if (allowedRepos.size > 0) core.info(`Allowed repos: ${[...allowedRepos].join(", ")}`);
-
-  let processedCount = 0;
-
-  /**
-   * Message handler function that processes a single add_labels message
-   * @param {AddLabelsMessage} message - The add_labels message to process
-   * @param {ResolvedTemporaryIds} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
-   * @returns {Promise<HandlerResult>} Result with success/error status
-   */
-  return async function handleAddLabels(message, resolvedTemporaryIds) {
-    // Check if we've hit the max limit
-    if (processedCount >= maxCount) {
-      core.warning(`Skipping add_labels: max count of ${maxCount} reached`);
-      return {
-        success: false,
-        error: `Max count of ${maxCount} reached`,
-      };
-    }
-
-    processedCount++;
-
-    // Resolve and validate target repository
-    const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "label");
-    if (!repoResult.success) {
-      core.warning(`Skipping add_labels: ${repoResult.error}`);
-      return {
-        success: false,
-        error: repoResult.error,
-      };
-    }
-    const { repo: itemRepo, repoParts } = repoResult;
-    core.info(`Target repository: ${itemRepo}`);
-
-    // Determine target issue/PR number
-    let itemNumber;
-    if (message.item_number !== undefined) {
-      // Resolve temporary IDs if present
-      const tempIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds);
-      const resolvedTarget = resolveRepoIssueTarget(message.item_number, tempIdMap, repoParts.owner, repoParts.repo);
-
-      // Check if this is an unresolved temporary ID
-      if (resolvedTarget.wasTemporaryId && !resolvedTarget.resolved) {
-        core.info(`Deferring add_labels: unresolved temporary ID (${message.item_number})`);
+const main = createCountGatedHandler({
+  handlerType: HANDLER_TYPE,
+  setup: async (config, maxCount) => {
+    const { allowed: allowedLabels = [], blocked: blockedPatterns = [] } = config;
+    const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
+    const githubClient = await createAuthenticatedGitHubClient(config);
+    const isStaged = isStagedMode(config);
+
+    core.info(`Add labels configuration: max=${maxCount}`);
+    if (allowedLabels.length > 0) core.info(`Allowed labels: ${allowedLabels.join(", ")}`);
+    if (blockedPatterns.length > 0) core.info(`Blocked patterns: ${blockedPatterns.join(", ")}`);
+    core.info(`Default target repo: ${defaultTargetRepo}`);
+    if (allowedRepos.size > 0) core.info(`Allowed repos: ${[...allowedRepos].join(", ")}`);
+
+    /**
+     * Message handler function that processes a single add_labels message
+     * @param {AddLabelsMessage} message - The add_labels message to process
+     * @param {ResolvedTemporaryIds} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
+     * @returns {Promise<HandlerResult>} Result with success/error status
+     */
+    return async function handleAddLabels(message, resolvedTemporaryIds) {
+      // Resolve and validate target repository
+      const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "label");
+      if (!repoResult.success) {
+        core.warning(`Skipping add_labels: ${repoResult.error}`);
         return {
           success: false,
-          deferred: true,
-          error: resolvedTarget.errorMessage || `Unresolved temporary ID: ${message.item_number}`,
+          error: repoResult.error,
         };
       }
+      const { repo: itemRepo, repoParts } = repoResult;
+      core.info(`Target repository: ${itemRepo}`);
+
+      // Determine target issue/PR number
+      let itemNumber;
+      if (message.item_number !== undefined) {
+        // Resolve temporary IDs if present
+        const tempIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds);
+        const resolvedTarget = resolveRepoIssueTarget(message.item_number, tempIdMap, repoParts.owner, repoParts.repo);
+
+        // Check if this is an unresolved temporary ID
+        if (resolvedTarget.wasTemporaryId && !resolvedTarget.resolved) {
+          core.info(`Deferring add_labels: unresolved temporary ID (${message.item_number})`);
+          return {
+            success: false,
+            deferred: true,
+            error: resolvedTarget.errorMessage || `Unresolved temporary ID: ${message.item_number}`,
+          };
+        }
+
+        // Check for other resolution errors
+        if (resolvedTarget.errorMessage || !resolvedTarget.resolved) {
+          const error = `Invalid item number: ${message.item_number}`;
+          core.warning(error);
+          return { success: false, error };
+        }
+
+        itemNumber = resolvedTarget.resolved.number;
+      } else {
+        itemNumber = context.payload?.issue?.number ?? context.payload?.pull_request?.number;
+      }
 
-      // Check for other resolution errors
-      if (resolvedTarget.errorMessage || !resolvedTarget.resolved) {
-        const error = `Invalid item number: ${message.item_number}`;
+      if (!itemNumber || isNaN(itemNumber)) {
+        const error = "No issue/PR number available";
         core.warning(error);
         return { success: false, error };
       }
 
-      itemNumber = resolvedTarget.resolved.number;
-    } else {
-      itemNumber = context.payload?.issue?.number ?? context.payload?.pull_request?.number;
-    }
-
-    if (!itemNumber || isNaN(itemNumber)) {
-      const error = "No issue/PR number available";
-      core.warning(error);
-      return { success: false, error };
-    }
-
-    const contextType = context.payload?.pull_request ? "pull request" : "issue";
-    const requestedLabels = message.labels ?? [];
-    core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`);
-
-    // If no labels provided, return a helpful message with allowed labels if configured
-    if (requestedLabels.length === 0) {
-      const labelSource = allowedLabels.length > 0 ? `the allowed list: ${JSON.stringify(allowedLabels)}` : "the repository's available labels";
-      const error = `No labels provided. Please provide at least one label from ${labelSource}`;
-      core.info(error);
-      return { success: false, error };
-    }
-
-    // Enforce max limits on labels before validation
-    const limitResult = tryEnforceArrayLimit(requestedLabels, MAX_LABELS, "labels");
-    if (!limitResult.success) {
-      core.warning(`Label limit exceeded: ${limitResult.error}`);
-      return { success: false, error: limitResult.error };
-    }
-
-    // Use validation helper to sanitize and validate labels
-    const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount, blockedPatterns);
-    if (!labelsResult.valid) {
-      // If no valid labels, log info and return gracefully
-      if (labelsResult.error?.includes("No valid labels")) {
+      const contextType = context.payload?.pull_request ? "pull request" : "issue";
+      const requestedLabels = message.labels ?? [];
+      core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`);
+
+      // If no labels provided, return a helpful message with allowed labels if configured
+      if (requestedLabels.length === 0) {
+        const labelSource = allowedLabels.length > 0 ? `the allowed list: ${JSON.stringify(allowedLabels)}` : "the repository's available labels";
+        const error = `No labels provided. Please provide at least one label from ${labelSource}`;
+        core.info(error);
+        return { success: false, error };
+      }
+
+      // Enforce max limits on labels before validation
+      const limitResult = tryEnforceArrayLimit(requestedLabels, MAX_LABELS, "labels");
+      if (!limitResult.success) {
+        core.warning(`Label limit exceeded: ${limitResult.error}`);
+        return { success: false, error: limitResult.error };
+      }
+
+      // Use validation helper to sanitize and validate labels
+      const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount, blockedPatterns);
+      if (!labelsResult.valid) {
+        // If no valid labels, log info and return gracefully
+        if (labelsResult.error?.includes("No valid labels")) {
+          core.info("No labels to add");
+          return {
+            success: true,
+            number: itemNumber,
+            labelsAdded: [],
+            message: "No valid labels found",
+          };
+        }
+        // For other validation errors, return error
+        core.warning(`Label validation failed: ${labelsResult.error}`);
+        return {
+          success: false,
+          error: labelsResult.error ?? "Invalid labels",
+        };
+      }
+
+      const uniqueLabels = labelsResult.value ?? [];
+
+      // Early return if no labels after validation
+      if (uniqueLabels.length === 0) {
         core.info("No labels to add");
         return {
           success: true,
           number: itemNumber,
           labelsAdded: [],
-          message: "No valid labels found",
+          message: "No labels to add",
         };
       }
-      // For other validation errors, return error
-      core.warning(`Label validation failed: ${labelsResult.error}`);
-      return {
-        success: false,
-        error: labelsResult.error ?? "Invalid labels",
-      };
-    }
-
-    const uniqueLabels = labelsResult.value ?? [];
-
-    // Early return if no labels after validation
-    if (uniqueLabels.length === 0) {
-      core.info("No labels to add");
-      return {
-        success: true,
-        number: itemNumber,
-        labelsAdded: [],
-        message: "No labels to add",
-      };
-    }
-
-    core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}: ${JSON.stringify(uniqueLabels)}`);
-
-    // If in staged mode, preview the labels without adding them
-    if (isStaged) {
-      logStagedPreviewInfo(`Would add ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}`);
-      return {
-        success: true,
-        staged: true,
-        previewInfo: {
-          number: itemNumber,
-          repo: itemRepo,
+
+      core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}: ${JSON.stringify(uniqueLabels)}`);
+
+      // If in staged mode, preview the labels without adding them
+      if (isStaged) {
+        logStagedPreviewInfo(`Would add ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}`);
+        return {
+          success: true,
+          staged: true,
+          previewInfo: {
+            number: itemNumber,
+            repo: itemRepo,
+            labels: uniqueLabels,
+            contextType,
+          },
+        };
+      }
+
+      try {
+        await githubClient.rest.issues.addLabels({
+          owner: repoParts.owner,
+          repo: repoParts.repo,
+          issue_number: itemNumber,
           labels: uniqueLabels,
+        });
+
+        core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}`);
+        return {
+          success: true,
+          number: itemNumber,
+          labelsAdded: uniqueLabels,
           contextType,
-        },
-      };
-    }
-
-    try {
-      await githubClient.rest.issues.addLabels({
-        owner: repoParts.owner,
-        repo: repoParts.repo,
-        issue_number: itemNumber,
-        labels: uniqueLabels,
-      });
-
-      core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}`);
-      return {
-        success: true,
-        number: itemNumber,
-        labelsAdded: uniqueLabels,
-        contextType,
-      };
-    } catch (error) {
-      const errorMessage = getErrorMessage(error);
-      core.error(`Failed to add labels: ${errorMessage}`);
-      return { success: false, error: errorMessage };
-    }
-  };
-}
+        };
+      } catch (error) {
+        const errorMessage = getErrorMessage(error);
+        core.error(`Failed to add labels: ${errorMessage}`);
+        return { success: false, error: errorMessage };
+      }
+    };
+  },
+});
 
 module.exports = { main };
diff --git a/actions/setup/js/assign_to_user.cjs b/actions/setup/js/assign_to_user.cjs
index 9ad6c89e167..473f2f25f2f 100644
--- a/actions/setup/js/assign_to_user.cjs
+++ b/actions/setup/js/assign_to_user.cjs
@@ -12,172 +12,161 @@ const { resolveIssueNumber, extractAssignees, isStagedMode } = require("./safe_o
 const { logStagedPreviewInfo } = require("./staged_preview.cjs");
 const { parseBoolTemplatable } = require("./templatable.cjs");
 const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
+const { createCountGatedHandler } = require("./handler_scaffold.cjs");
 
 /** @type {string} Safe output type handled by this module */
 const HANDLER_TYPE = "assign_to_user";
 
 /**
  * Main handler factory for assign_to_user
- * Returns a message handler function that processes individual assign_to_user messages
+ * Uses shared count-gated scaffold for max-limit enforcement.
  * @type {HandlerFactoryFunction}
  */
-async function main(config = {}) {
-  // Extract configuration
-  const allowedAssignees = config.allowed ?? [];
-  const blockedAssignees = config.blocked ?? [];
-  const maxCount = config.max ?? 10;
-  const unassignFirst = parseBoolTemplatable(config.unassign_first, false);
-  const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
-  const githubClient = await createAuthenticatedGitHubClient(config);
-
-  // Check if we're in staged mode
-  const isStaged = isStagedMode(config);
-
-  core.info(`Assign to user configuration: max=${maxCount}, unassign_first=${unassignFirst}`);
-  if (allowedAssignees.length > 0) {
-    core.info(`Allowed assignees: ${allowedAssignees.join(", ")}`);
-  }
-  if (blockedAssignees.length > 0) {
-    core.info(`Blocked assignees: ${blockedAssignees.join(", ")}`);
-  }
-  core.info(`Default target repo: ${defaultTargetRepo}`);
-  if (allowedRepos.size > 0) {
-    core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
-  }
-
-  // Track how many items we've processed for max limit
-  let processedCount = 0;
-
-  /**
-   * Message handler function that processes a single assign_to_user message
-   * @param {Object} message - The assign_to_user message to process
-   * @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
-   * @returns {Promise<Object>} Result with success/error status
-   */
-  return async function handleAssignToUser(message, resolvedTemporaryIds) {
-    // Check if we've hit the max limit
-    if (processedCount >= maxCount) {
-      core.warning(`Skipping assign_to_user: max count of ${maxCount} reached`);
-      return {
-        success: false,
-        error: `Max count of ${maxCount} reached`,
-      };
+const main = createCountGatedHandler({
+  handlerType: HANDLER_TYPE,
+  setup: async (config, maxCount) => {
+    // Extract configuration
+    const allowedAssignees = config.allowed ?? [];
+    const blockedAssignees = config.blocked ?? [];
+    const unassignFirst = parseBoolTemplatable(config.unassign_first, false);
+    const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
+    const githubClient = await createAuthenticatedGitHubClient(config);
+
+    // Check if we're in staged mode
+    const isStaged = isStagedMode(config);
+
+    core.info(`Assign to user configuration: max=${maxCount}, unassign_first=${unassignFirst}`);
+    if (allowedAssignees.length > 0) {
+      core.info(`Allowed assignees: ${allowedAssignees.join(", ")}`);
     }
-
-    processedCount++;
-
-    // Resolve and validate target repository
-    const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "assignee");
-    if (!repoResult.success) {
-      core.warning(`Skipping assign_to_user: ${repoResult.error}`);
-      return {
-        success: false,
-        error: repoResult.error,
-      };
+    if (blockedAssignees.length > 0) {
+      core.info(`Blocked assignees: ${blockedAssignees.join(", ")}`);
     }
-    const { repo: itemRepo, repoParts } = repoResult;
-    core.info(`Target repository: ${itemRepo}`);
-
-    const assignItem = message;
-
-    // Determine issue number using shared helper
-    const issueResult = resolveIssueNumber(assignItem);
-    if (!issueResult.success) {
-      core.warning(`Skipping assign_to_user: ${issueResult.error}`);
-      return {
-        success: false,
-        error: issueResult.error,
-      };
+    core.info(`Default target repo: ${defaultTargetRepo}`);
+    if (allowedRepos.size > 0) {
+      core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
     }
-    const issueNumber = issueResult.issueNumber;
 
-    // Extract assignees using shared helper
-    const requestedAssignees = extractAssignees(assignItem);
-
-    core.info(`Requested assignees: ${JSON.stringify(requestedAssignees)}`);
+    /**
+     * Message handler function that processes a single assign_to_user message
+     * @param {Object} message - The assign_to_user message to process
+     * @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
+     * @returns {Promise<Object>} Result with success/error status
+     */
+    return async function handleAssignToUser(message, resolvedTemporaryIds) {
+      // Resolve and validate target repository
+      const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "assignee");
+      if (!repoResult.success) {
+        core.warning(`Skipping assign_to_user: ${repoResult.error}`);
+        return {
+          success: false,
+          error: repoResult.error,
+        };
+      }
+      const { repo: itemRepo, repoParts } = repoResult;
+      core.info(`Target repository: ${itemRepo}`);
+
+      const assignItem = message;
+
+      // Determine issue number using shared helper
+      const issueResult = resolveIssueNumber(assignItem);
+      if (!issueResult.success) {
+        core.warning(`Skipping assign_to_user: ${issueResult.error}`);
+        return {
+          success: false,
+          error: issueResult.error,
+        };
+      }
+      const issueNumber = issueResult.issueNumber;
 
-    // Use shared helper to filter, sanitize, dedupe, and limit
-    const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount, blockedAssignees);
+      // Extract assignees using shared helper
+      const requestedAssignees = extractAssignees(assignItem);
 
-    if (uniqueAssignees.length === 0) {
-      core.info("No assignees to add");
-      return {
-        success: true,
-        issueNumber: issueNumber,
-        assigneesAdded: [],
-        message: "No valid assignees found",
-      };
-    }
+      core.info(`Requested assignees: ${JSON.stringify(requestedAssignees)}`);
 
-    core.info(`Assigning ${uniqueAssignees.length} users to issue #${issueNumber} in ${itemRepo}: ${JSON.stringify(uniqueAssignees)}`);
+      // Use shared helper to filter, sanitize, dedupe, and limit
+      const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount, blockedAssignees);
 
-    // If in staged mode, preview without executing
-    if (isStaged) {
-      logStagedPreviewInfo(`Would assign users to issue #${issueNumber} in ${itemRepo}`);
-      if (unassignFirst) {
-        logStagedPreviewInfo(`Would unassign all current assignees first`);
+      if (uniqueAssignees.length === 0) {
+        core.info("No assignees to add");
+        return {
+          success: true,
+          issueNumber: issueNumber,
+          assigneesAdded: [],
+          message: "No valid assignees found",
+        };
       }
-      return {
-        success: true,
-        staged: true,
-        previewInfo: {
-          issueNumber,
-          repo: itemRepo,
-          assignees: uniqueAssignees,
-          unassignFirst,
-        },
-      };
-    }
 
-    try {
-      // If unassign_first is enabled, get current assignees and remove them first
-      if (unassignFirst) {
-        core.info(`Fetching current assignees for issue #${issueNumber} to unassign them first`);
-        const issue = await githubClient.rest.issues.get({
-          owner: repoParts.owner,
-          repo: repoParts.repo,
-          issue_number: issueNumber,
-        });
+      core.info(`Assigning ${uniqueAssignees.length} users to issue #${issueNumber} in ${itemRepo}: ${JSON.stringify(uniqueAssignees)}`);
 
-        const currentAssignees = issue.data.assignees?.map(a => a.login) || [];
-        if (currentAssignees.length > 0) {
-          core.info(`Unassigning ${currentAssignees.length} current assignee(s): ${JSON.stringify(currentAssignees)}`);
-          await githubClient.rest.issues.removeAssignees({
+      // If in staged mode, preview without executing
+      if (isStaged) {
+        logStagedPreviewInfo(`Would assign users to issue #${issueNumber} in ${itemRepo}`);
+        if (unassignFirst) {
+          logStagedPreviewInfo(`Would unassign all current assignees first`);
+        }
+        return {
+          success: true,
+          staged: true,
+          previewInfo: {
+            issueNumber,
+            repo: itemRepo,
+            assignees: uniqueAssignees,
+            unassignFirst,
+          },
+        };
+      }
+
+      try {
+        // If unassign_first is enabled, get current assignees and remove them first
+        if (unassignFirst) {
+          core.info(`Fetching current assignees for issue #${issueNumber} to unassign them first`);
+          const issue = await githubClient.rest.issues.get({
             owner: repoParts.owner,
             repo: repoParts.repo,
             issue_number: issueNumber,
-            assignees: currentAssignees,
           });
-          core.info(`Successfully unassigned current assignees`);
-        } else {
-          core.info(`No current assignees to unassign`);
+
+          const currentAssignees = issue.data.assignees?.map(a => a.login) || [];
+          if (currentAssignees.length > 0) {
+            core.info(`Unassigning ${currentAssignees.length} current assignee(s): ${JSON.stringify(currentAssignees)}`);
+            await githubClient.rest.issues.removeAssignees({
+              owner: repoParts.owner,
+              repo: repoParts.repo,
+              issue_number: issueNumber,
+              assignees: currentAssignees,
+            });
+            core.info(`Successfully unassigned current assignees`);
+          } else {
+            core.info(`No current assignees to unassign`);
+          }
         }
-      }
 
-      // Add assignees to the issue
-      await githubClient.rest.issues.addAssignees({
-        owner: repoParts.owner,
-        repo: repoParts.repo,
-        issue_number: issueNumber,
-        assignees: uniqueAssignees,
-      });
-
-      core.info(`Successfully assigned ${uniqueAssignees.length} user(s) to issue #${issueNumber} in ${itemRepo}`);
-
-      return {
-        success: true,
-        issueNumber: issueNumber,
-        assigneesAdded: uniqueAssignees,
-      };
-    } catch (error) {
-      const errorMessage = getErrorMessage(error);
-      core.error(`Failed to assign users: ${errorMessage}`);
-      return {
-        success: false,
-        error: errorMessage,
-      };
-    }
-  };
-}
+        // Add assignees to the issue
+        await githubClient.rest.issues.addAssignees({
+          owner: repoParts.owner,
+          repo: repoParts.repo,
+          issue_number: issueNumber,
+          assignees: uniqueAssignees,
+        });
+
+        core.info(`Successfully assigned ${uniqueAssignees.length} user(s) to issue #${issueNumber} in ${itemRepo}`);
+
+        return {
+          success: true,
+          issueNumber: issueNumber,
+          assigneesAdded: uniqueAssignees,
+        };
+      } catch (error) {
+        const errorMessage = getErrorMessage(error);
+        core.error(`Failed to assign users: ${errorMessage}`);
+        return {
+          success: false,
+          error: errorMessage,
+        };
+      }
+    };
+  },
+});
 
 module.exports = { main };
diff --git a/actions/setup/js/handler_scaffold.cjs b/actions/setup/js/handler_scaffold.cjs
new file mode 100644
index 00000000000..ebf4579900e
--- /dev/null
+++ b/actions/setup/js/handler_scaffold.cjs
@@ -0,0 +1,54 @@
+// @ts-check
+/// <reference types="@actions/github-script" />
+
+/**
+ * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
+ * @typedef {import('./types/handler-factory').MessageHandlerFunction} MessageHandlerFunction
+ * @typedef {import('./types/handler-factory').HandlerResult} HandlerResult
+ */
+
+/**
+ * @typedef {Object} CountGatedHandlerConfig
+ * @property {string} handlerType - Handler type name used in log/warning messages (e.g., "add_labels")
+ * @property {(config: Object, maxCount: number) => Promise<MessageHandlerFunction>} setup
+ *   Setup function that receives the handler config and resolved maxCount,
+ *   performs handler-specific initialization (GitHub client, logging, etc.),
+ *   and returns the message handler function for processing individual items.
+ */
+
+/**
+ * Creates a handler factory with shared max-count gating and processedCount state.
+ *
+ * This eliminates the duplicated scaffolding across safe-output handlers:
+ * - Extracts maxCount from config (default 10)
+ * - Maintains processedCount in closure
+ * - Gates each call with the max-count check
+ * - Returns standardized error envelope when limit is reached
+ *
+ * @param {CountGatedHandlerConfig} options - Handler configuration
+ * @returns {HandlerFactoryFunction} Handler factory function compatible with the handler manager
+ */
+function createCountGatedHandler({ handlerType, setup }) {
+  return async function main(config = {}) {
+    const maxCount = config.max ?? 10;
+    let processedCount = 0;
+
+    const handleItem = await setup(config, maxCount);
+
+    /** @type {MessageHandlerFunction} */
+    return async function handler(message, resolvedTemporaryIds) {
+      if (processedCount >= maxCount) {
+        core.warning(`Skipping ${handlerType}: max count of ${maxCount} reached`);
+        return {
+          success: false,
+          error: `Max count of ${maxCount} reached`,
+        };
+      }
+
+      processedCount++;
+      return handleItem(message, resolvedTemporaryIds);
+    };
+  };
+}
+
+module.exports = { createCountGatedHandler };
diff --git a/actions/setup/js/handler_scaffold.test.cjs b/actions/setup/js/handler_scaffold.test.cjs
new file mode 100644
index 00000000000..4511541e96b
--- /dev/null
+++ b/actions/setup/js/handler_scaffold.test.cjs
@@ -0,0 +1,154 @@
+// @ts-check
+import { describe, it, expect, beforeEach, vi } from "vitest";
+const { createCountGatedHandler } = require("./handler_scaffold.cjs");
+
+describe("handler_scaffold", () => {
+  beforeEach(() => {
+    global.core = {
+      info: vi.fn(),
+      warning: vi.fn(),
+      error: vi.fn(),
+      debug: vi.fn(),
+    };
+  });
+
+  describe("createCountGatedHandler", () => {
+    it("should create a handler factory that returns a function", async () => {
+      const factory = createCountGatedHandler({
+        handlerType: "test_handler",
+        setup: async () => async () => ({ success: true }),
+      });
+
+      const handler = await factory({ max: 5 });
+      expect(typeof handler).toBe("function");
+    });
+
+    it("should pass config and maxCount to setup function", async () => {
+      const setupSpy = vi.fn().mockResolvedValue(async () => ({ success: true }));
+
+      const factory = createCountGatedHandler({
+        handlerType: "test_handler",
+        setup: setupSpy,
+      });
+
+      const config = { max: 7, allowed: ["a", "b"] };
+      await factory(config);
+
+      expect(setupSpy).toHaveBeenCalledWith(config, 7);
+    });
+
+    it("should default maxCount to 10 when not specified", async () => {
+      const setupSpy = vi.fn().mockResolvedValue(async () => ({ success: true }));
+
+      const factory = createCountGatedHandler({
+        handlerType: "test_handler",
+        setup: setupSpy,
+      });
+
+      await factory({});
+      expect(setupSpy).toHaveBeenCalledWith({}, 10);
+    });
+
+    it("should delegate to handleItem when under the limit", async () => {
+      const handleItem = vi.fn().mockResolvedValue({ success: true, data: "result" });
+
+      const factory = createCountGatedHandler({
+        handlerType: "test_handler",
+        setup: async () => handleItem,
+      });
+
+      const handler = await factory({ max: 5 });
+      const result = await handler({ key: "value" }, { tempId: { repo: "o/r", number: 1 } });
+
+      expect(result).toEqual({ success: true, data: "result" });
+      expect(handleItem).toHaveBeenCalledWith({ key: "value" }, { tempId: { repo: "o/r", number: 1 } });
+    });
+
+    it("should respect max count limit", async () => {
+      let callCount = 0;
+      const handleItem = vi.fn().mockImplementation(async () => {
+        callCount++;
+        return { success: true, count: callCount };
+      });
+
+      const factory = createCountGatedHandler({
+        handlerType: "test_handler",
+        setup: async () => handleItem,
+      });
+
+      const handler = await factory({ max: 2 });
+
+      const result1 = await handler({}, {});
+      expect(result1.success).toBe(true);
+
+      const result2 = await handler({}, {});
+      expect(result2.success).toBe(true);
+
+      const result3 = await handler({}, {});
+      expect(result3.success).toBe(false);
+      expect(result3.error).toContain("Max count of 2 reached");
+
+      expect(handleItem).toHaveBeenCalledTimes(2);
+    });
+
+    it("should log warning when max count is reached", async () => {
+      const factory = createCountGatedHandler({
+        handlerType: "my_handler",
+        setup: async () => async () => ({ success: true }),
+      });
+
+      const handler = await factory({ max: 1 });
+      await handler({}, {});
+      await handler({}, {});
+
+      expect(global.core.warning).toHaveBeenCalledWith("Skipping my_handler: max count of 1 reached");
+    });
+
+    it("should count failed handler calls toward the limit", async () => {
+      const handleItem = vi.fn().mockResolvedValue({ success: false, error: "handler error" });
+
+      const factory = createCountGatedHandler({
+        handlerType: "test_handler",
+        setup: async () => handleItem,
+      });
+
+      const handler = await factory({ max: 2 });
+
+      const result1 = await handler({}, {});
+      expect(result1.success).toBe(false);
+      expect(result1.error).toBe("handler error");
+
+      const result2 = await handler({}, {});
+      expect(result2.success).toBe(false);
+      expect(result2.error).toBe("handler error");
+
+      const result3 = await handler({}, {});
+      expect(result3.success).toBe(false);
+      expect(result3.error).toContain("Max count of 2 reached");
+    });
+
+    it("should maintain independent counts across separate factory invocations", async () => {
+      const factory = createCountGatedHandler({
+        handlerType: "test_handler",
+        setup: async () => async () => ({ success: true }),
+      });
+
+      const handler1 = await factory({ max: 1 });
+      const handler2 = await factory({ max: 1 });
+
+      const result1a = await handler1({}, {});
+      expect(result1a.success).toBe(true);
+
+      const result2a = await handler2({}, {});
+      expect(result2a.success).toBe(true);
+
+      const result1b = await handler1({}, {});
+      expect(result1b.success).toBe(false);
+      expect(result1b.error).toContain("Max count");
+
+      const result2b = await handler2({}, {});
+      expect(result2b.success).toBe(false);
+      expect(result2b.error).toContain("Max count");
+    });
+  });
+});
diff --git a/actions/setup/js/remove_labels.cjs b/actions/setup/js/remove_labels.cjs
index e6a9a4f1259..1ad7f46ab4d 100644
--- a/actions/setup/js/remove_labels.cjs
+++ b/actions/setup/js/remove_labels.cjs
@@ -15,211 +15,200 @@ const { logStagedPreviewInfo } = require("./staged_preview.cjs");
 const { isStagedMode } = require("./safe_output_helpers.cjs");
 const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
 const { resolveRepoIssueTarget, loadTemporaryIdMapFromResolved } = require("./temporary_id.cjs");
+const { createCountGatedHandler } = require("./handler_scaffold.cjs");
 
 /**
  * Main handler factory for remove_labels
- * Returns a message handler function that processes individual remove_labels messages
+ * Uses shared count-gated scaffold for max-limit enforcement.
  * @type {HandlerFactoryFunction}
  */
-async function main(config = {}) {
-  // Extract configuration
-  const allowedLabels = config.allowed || [];
-  const blockedPatterns = config.blocked || [];
-  const maxCount = config.max || 10;
-  const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
-  const githubClient = await createAuthenticatedGitHubClient(config);
-
-  // Check if we're in staged mode
-  const isStaged = isStagedMode(config);
-
-  core.info(`Remove labels configuration: max=${maxCount}`);
-  if (allowedLabels.length > 0) {
-    core.info(`Allowed labels to remove: ${allowedLabels.join(", ")}`);
-  }
-  if (blockedPatterns.length > 0) {
-    core.info(`Blocked patterns: ${blockedPatterns.join(", ")}`);
-  }
-  core.info(`Default target repo: ${defaultTargetRepo}`);
-  if (allowedRepos.size > 0) {
-    core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
-  }
-
-  // Track how many items we've processed for max limit
-  let processedCount = 0;
-
-  /**
-   * Message handler function that processes a single remove_labels message
-   * @param {Object} message - The remove_labels message to process
-   * @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
-   * @returns {Promise<Object>} Result with success/error status
-   */
-  return async function handleRemoveLabels(message, resolvedTemporaryIds) {
-    // Check if we've hit the max limit
-    if (processedCount >= maxCount) {
-      core.warning(`Skipping remove_labels: max count of ${maxCount} reached`);
-      return {
-        success: false,
-        error: `Max count of ${maxCount} reached`,
-      };
+const main = createCountGatedHandler({
+  handlerType: HANDLER_TYPE,
+  setup: async (config, maxCount) => {
+    // Extract configuration
+    const allowedLabels = config.allowed || [];
+    const blockedPatterns = config.blocked || [];
+    const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
+    const githubClient = await createAuthenticatedGitHubClient(config);
+
+    // Check if we're in staged mode
+    const isStaged = isStagedMode(config);
+
+    core.info(`Remove labels configuration: max=${maxCount}`);
+    if (allowedLabels.length > 0) {
+      core.info(`Allowed labels to remove: ${allowedLabels.join(", ")}`);
     }
-
-    processedCount++;
-
-    // Resolve and validate target repository
-    const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "label");
-    if (!repoResult.success) {
-      core.warning(`Skipping remove_labels: ${repoResult.error}`);
-      return {
-        success: false,
-        error: repoResult.error,
-      };
+    if (blockedPatterns.length > 0) {
+      core.info(`Blocked patterns: ${blockedPatterns.join(", ")}`);
     }
-    const { repo: itemRepo, repoParts } = repoResult;
-    core.info(`Target repository: ${itemRepo}`);
-
-    // Determine target issue/PR number
-    let itemNumber;
-    if (message.item_number !== undefined) {
-      // Resolve temporary IDs if present
-      const tempIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds);
-      const resolvedTarget = resolveRepoIssueTarget(message.item_number, tempIdMap, repoParts.owner, repoParts.repo);
-
-      // Check if this is an unresolved temporary ID
-      if (resolvedTarget.wasTemporaryId && !resolvedTarget.resolved) {
-        core.info(`Deferring remove_labels: unresolved temporary ID (${message.item_number})`);
+    core.info(`Default target repo: ${defaultTargetRepo}`);
+    if (allowedRepos.size > 0) {
+      core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
+    }
+
+    /**
+     * Message handler function that processes a single remove_labels message
+     * @param {Object} message - The remove_labels message to process
+     * @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
+     * @returns {Promise<Object>} Result with success/error status
+     */
+    return async function handleRemoveLabels(message, resolvedTemporaryIds) {
+      // Resolve and validate target repository
+      const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "label");
+      if (!repoResult.success) {
+        core.warning(`Skipping remove_labels: ${repoResult.error}`);
         return {
           success: false,
-          deferred: true,
-          error: resolvedTarget.errorMessage || `Unresolved temporary ID: ${message.item_number}`,
+          error: repoResult.error,
         };
       }
+      const { repo: itemRepo, repoParts } = repoResult;
+      core.info(`Target repository: ${itemRepo}`);
+
+      // Determine target issue/PR number
+      let itemNumber;
+      if (message.item_number !== undefined) {
+        // Resolve temporary IDs if present
+        const tempIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds);
+        const resolvedTarget = resolveRepoIssueTarget(message.item_number, tempIdMap, repoParts.owner, repoParts.repo);
+
+        // Check if this is an unresolved temporary ID
+        if (resolvedTarget.wasTemporaryId && !resolvedTarget.resolved) {
+          core.info(`Deferring remove_labels: unresolved temporary ID (${message.item_number})`);
+          return {
+            success: false,
+            deferred: true,
+            error: resolvedTarget.errorMessage || `Unresolved temporary ID: ${message.item_number}`,
+          };
+        }
+
+        // Check for other resolution errors
+        if (resolvedTarget.errorMessage || !resolvedTarget.resolved) {
+          const error = `Invalid item number: ${message.item_number}`;
+          core.warning(error);
+          return { success: false, error };
+        }
 
-      // Check for other resolution errors
-      if (resolvedTarget.errorMessage || !resolvedTarget.resolved) {
-        const error = `Invalid item number: ${message.item_number}`;
+        itemNumber = resolvedTarget.resolved.number;
+      } else {
+        itemNumber = context.payload?.issue?.number || context.payload?.pull_request?.number;
+      }
+
+      if (!itemNumber || isNaN(itemNumber)) {
+        const error = "No issue/PR number available";
         core.warning(error);
         return { success: false, error };
       }
 
-      itemNumber = resolvedTarget.resolved.number;
-    } else {
-      itemNumber = context.payload?.issue?.number || context.payload?.pull_request?.number;
-    }
-
-    if (!itemNumber || isNaN(itemNumber)) {
-      const error = "No issue/PR number available";
-      core.warning(error);
-      return { success: false, error };
-    }
+      const contextType = context.payload?.pull_request ? "pull request" : "issue";
+      const requestedLabels = message.labels ?? [];
+      core.info(`Requested labels to remove: ${JSON.stringify(requestedLabels)}`);
 
-    const contextType = context.payload?.pull_request ? "pull request" : "issue";
-    const requestedLabels = message.labels ?? [];
-    core.info(`Requested labels to remove: ${JSON.stringify(requestedLabels)}`);
+      // If no labels provided, return a helpful message with allowed labels if configured
+      if (!requestedLabels || requestedLabels.length === 0) {
+        let errorMessage = "No labels provided. Please provide at least one label from";
+        if (allowedLabels.length > 0) {
+          errorMessage += ` the allowed list: ${JSON.stringify(allowedLabels)}`;
+        } else {
+          errorMessage += " the issue/PR's current labels";
+        }
+        core.info(errorMessage);
+        return {
+          success: false,
+          error: errorMessage,
+        };
+      }
 
-    // If no labels provided, return a helpful message with allowed labels if configured
-    if (!requestedLabels || requestedLabels.length === 0) {
-      let errorMessage = "No labels provided. Please provide at least one label from";
-      if (allowedLabels.length > 0) {
-        errorMessage += ` the allowed list: ${JSON.stringify(allowedLabels)}`;
-      } else {
-        errorMessage += " the issue/PR's current labels";
+      // Use validation helper to sanitize and validate labels
+      const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount, blockedPatterns);
+      if (!labelsResult.valid) {
+        // If no valid labels, log info and return gracefully
+        if (labelsResult.error?.includes("No valid labels")) {
+          core.info("No labels to remove");
+          return {
+            success: true,
+            number: itemNumber,
+            labelsRemoved: [],
+            message: "No valid labels found",
+          };
+        }
+        // For other validation errors, return error
+        core.warning(`Label validation failed: ${labelsResult.error}`);
+        return {
+          success: false,
+          error: labelsResult.error ?? "Invalid labels",
+        };
       }
-      core.info(errorMessage);
-      return {
-        success: false,
-        error: errorMessage,
-      };
-    }
 
-    // Use validation helper to sanitize and validate labels
-    const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount, blockedPatterns);
-    if (!labelsResult.valid) {
-      // If no valid labels, log info and return gracefully
-      if (labelsResult.error?.includes("No valid labels")) {
+      const uniqueLabels = labelsResult.value ?? [];
+
+      if (uniqueLabels.length === 0) {
         core.info("No labels to remove");
         return {
           success: true,
           number: itemNumber,
           labelsRemoved: [],
-          message: "No valid labels found",
+          message: "No labels to remove",
         };
       }
-      // For other validation errors, return error
-      core.warning(`Label validation failed: ${labelsResult.error}`);
-      return {
-        success: false,
-        error: labelsResult.error ?? "Invalid labels",
-      };
-    }
 
-    const uniqueLabels = labelsResult.value ?? [];
+      core.info(`Removing ${uniqueLabels.length} labels from ${contextType} #${itemNumber} in ${itemRepo}: ${JSON.stringify(uniqueLabels)}`);
 
-    if (uniqueLabels.length === 0) {
-      core.info("No labels to remove");
-      return {
-        success: true,
-        number: itemNumber,
-        labelsRemoved: [],
-        message: "No labels to remove",
-      };
-    }
-
-    core.info(`Removing ${uniqueLabels.length} labels from ${contextType} #${itemNumber} in ${itemRepo}: ${JSON.stringify(uniqueLabels)}`);
-
-    // If in staged mode, preview the label removal without actually removing
-    if (isStaged) {
-      logStagedPreviewInfo(`Would remove ${uniqueLabels.length} labels from ${contextType} #${itemNumber} in ${itemRepo}`);
-      return {
-        success: true,
-        staged: true,
-        previewInfo: {
-          number: itemNumber,
-          repo: itemRepo,
-          labels: uniqueLabels,
-          contextType,
-        },
-      };
-    }
+      // If in staged mode, preview the label removal without actually removing
+      if (isStaged) {
+        logStagedPreviewInfo(`Would remove ${uniqueLabels.length} labels from ${contextType} #${itemNumber} in ${itemRepo}`);
+        return {
+          success: true,
+          staged: true,
+          previewInfo: {
+            number: itemNumber,
+            repo: itemRepo,
+            labels: uniqueLabels,
+            contextType,
+          },
+        };
+      }
 
-    // Track successfully removed labels
-    const removedLabels = [];
-    const failedLabels = [];
-
-    // Remove labels one at a time (GitHub API doesn't have a bulk remove endpoint)
-    for (const label of uniqueLabels) {
-      try {
-        await githubClient.rest.issues.removeLabel({
-          owner: repoParts.owner,
-          repo: repoParts.repo,
-          issue_number: itemNumber,
-          name: label,
-        });
-        removedLabels.push(label);
-        core.info(`Removed label "${label}" from ${contextType} #${itemNumber} in ${itemRepo}`);
-      } catch (error) {
-        // Label might not exist on the issue/PR - this is not a failure
-        const errorMessage = getErrorMessage(error);
-        if (errorMessage.includes("Label does not exist") || errorMessage.includes("404")) {
-          core.info(`Label "${label}" was not present on ${contextType} #${itemNumber} in ${itemRepo}, skipping`);
-        } else {
-          core.warning(`Failed to remove label "${label}": ${errorMessage}`);
-          failedLabels.push({ label, error: errorMessage });
+      // Track successfully removed labels
+      const removedLabels = [];
+      const failedLabels = [];
+
+      // Remove labels one at a time (GitHub API doesn't have a bulk remove endpoint)
+      for (const label of uniqueLabels) {
+        try {
+          await githubClient.rest.issues.removeLabel({
+            owner: repoParts.owner,
+            repo: repoParts.repo,
+            issue_number: itemNumber,
+            name: label,
+          });
+          removedLabels.push(label);
+          core.info(`Removed label "${label}" from ${contextType} #${itemNumber} in ${itemRepo}`);
+        } catch (error) {
+          // Label might not exist on the issue/PR - this is not a failure
+          const errorMessage = getErrorMessage(error);
+          if (errorMessage.includes("Label does not exist") || errorMessage.includes("404")) {
+            core.info(`Label "${label}" was not present on ${contextType} #${itemNumber} in ${itemRepo}, skipping`);
+          } else {
+            core.warning(`Failed to remove label "${label}": ${errorMessage}`);
+            failedLabels.push({ label, error: errorMessage });
+          }
         }
       }
-    }
 
-    if (removedLabels.length > 0) {
-      core.info(`Successfully removed ${removedLabels.length} labels from ${contextType} #${itemNumber} in ${itemRepo}`);
-    }
+      if (removedLabels.length > 0) {
+        core.info(`Successfully removed ${removedLabels.length} labels from ${contextType} #${itemNumber} in ${itemRepo}`);
+      }
 
-    return {
-      success: true,
-      number: itemNumber,
-      labelsRemoved: removedLabels,
-      failedLabels: failedLabels.length > 0 ? failedLabels : undefined,
-      contextType,
+      return {
+        success: true,
+        number: itemNumber,
+        labelsRemoved: removedLabels,
+        failedLabels: failedLabels.length > 0 ? failedLabels : undefined,
+        contextType,
+      };
     };
-  };
-}
+  },
+});
 
 module.exports = { main };
diff --git a/actions/setup/js/unassign_from_user.cjs b/actions/setup/js/unassign_from_user.cjs
index de4a6c9fb2c..34b239a6aa8 100644
--- a/actions/setup/js/unassign_from_user.cjs
+++ b/actions/setup/js/unassign_from_user.cjs
@@ -11,148 +11,137 @@ const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_help
 const { resolveIssueNumber, extractAssignees, isStagedMode } = require("./safe_output_helpers.cjs");
 const { logStagedPreviewInfo } = require("./staged_preview.cjs");
 const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
+const { createCountGatedHandler } = require("./handler_scaffold.cjs");
 
 /** @type {string} Safe output type handled by this module */
 const HANDLER_TYPE = "unassign_from_user";
 
 /**
  * Main handler factory for unassign_from_user
- * Returns a message handler function that processes individual unassign_from_user messages
+ * Uses shared count-gated scaffold for max-limit enforcement.
  * @type {HandlerFactoryFunction}
  */
-async function main(config = {}) {
-  // Extract configuration
-  const allowedAssignees = config.allowed || [];
-  const blockedAssignees = config.blocked || [];
-  const maxCount = config.max || 10;
-
-  // Resolve target repository configuration
-  const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
-  const githubClient = await createAuthenticatedGitHubClient(config);
-
-  // Check if we're in staged mode
-  const isStaged = isStagedMode(config);
-
-  core.info(`Unassign from user configuration: max=${maxCount}`);
-  if (allowedAssignees.length > 0) {
-    core.info(`Allowed assignees to unassign: ${allowedAssignees.join(", ")}`);
-  }
-  if (blockedAssignees.length > 0) {
-    core.info(`Blocked assignees to unassign: ${blockedAssignees.join(", ")}`);
-  }
-  core.info(`Default target repository: ${defaultTargetRepo}`);
-  if (allowedRepos.size > 0) {
-    core.info(`Additional allowed repositories: ${Array.from(allowedRepos).join(", ")}`);
-  }
-
-  // Track how many items we've processed for max limit
-  let processedCount = 0;
-
-  /**
-   * Message handler function that processes a single unassign_from_user message
-   * @param {Object} message - The unassign_from_user message to process
-   * @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
-   * @returns {Promise<Object>} Result with success/error status
-   */
-  return async function handleUnassignFromUser(message, resolvedTemporaryIds) {
-    // Check if we've hit the max limit
-    if (processedCount >= maxCount) {
-      core.warning(`Skipping unassign_from_user: max count of ${maxCount} reached`);
-      return {
-        success: false,
-        error: `Max count of ${maxCount} reached`,
-      };
+const main = createCountGatedHandler({
+  handlerType: HANDLER_TYPE,
+  setup: async (config, maxCount) => {
+    // Extract configuration
+    const allowedAssignees = config.allowed || [];
+    const blockedAssignees = config.blocked || [];
+
+    // Resolve target repository configuration
+    const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
+    const githubClient = await createAuthenticatedGitHubClient(config);
+
+    // Check if we're in staged mode
+    const isStaged = isStagedMode(config);
+
+    core.info(`Unassign from user configuration: max=${maxCount}`);
+    if (allowedAssignees.length > 0) {
+      core.info(`Allowed assignees to unassign: ${allowedAssignees.join(", ")}`);
     }
-
-    processedCount++;
-
-    const unassignItem = message;
-
-    // Determine issue number using shared helper
-    const issueResult = resolveIssueNumber(unassignItem);
-    if (!issueResult.success) {
-      core.warning(`Skipping unassign_from_user: ${issueResult.error}`);
-      return {
-        success: false,
-        error: issueResult.error,
-      };
+    if (blockedAssignees.length > 0) {
+      core.info(`Blocked assignees to unassign: ${blockedAssignees.join(", ")}`);
     }
-    const issueNumber = issueResult.issueNumber;
-
-    // Extract assignees using shared helper
-    const requestedAssignees = extractAssignees(unassignItem);
-
-    core.info(`Requested assignees to unassign: ${JSON.stringify(requestedAssignees)}`);
-
-    // Use shared helper to filter, sanitize, dedupe, and limit
-    const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount, blockedAssignees);
-
-    if (uniqueAssignees.length === 0) {
-      core.info("No assignees to remove");
-      return {
-        success: true,
-        issueNumber: issueNumber,
-        assigneesRemoved: [],
-        message: "No valid assignees found",
-      };
+    core.info(`Default target repository: ${defaultTargetRepo}`);
+    if (allowedRepos.size > 0) {
+      core.info(`Additional allowed repositories: ${Array.from(allowedRepos).join(", ")}`);
     }
 
-    // Resolve and validate target repository
-    const repoResult = resolveAndValidateRepo(unassignItem, defaultTargetRepo, allowedRepos, "issue");
-
-    if (!repoResult.success) {
-      core.warning(`Repository validation failed: ${repoResult.error}`);
-      return {
-        success: false,
-        error: repoResult.error,
-      };
-    }
-
-    const repoParts = repoResult.repoParts;
-    const targetRepo = repoResult.repo;
+    /**
+     * Message handler function that processes a single unassign_from_user message
+     * @param {Object} message - The unassign_from_user message to process
+     * @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
+     * @returns {Promise<Object>} Result with success/error status
+     */
+    return async function handleUnassignFromUser(message, resolvedTemporaryIds) {
+      const unassignItem = message;
+
+      // Determine issue number using shared helper
+      const issueResult = resolveIssueNumber(unassignItem);
+      if (!issueResult.success) {
+        core.warning(`Skipping unassign_from_user: ${issueResult.error}`);
+        return {
+          success: false,
+          error: issueResult.error,
+        };
+      }
+      const issueNumber = issueResult.issueNumber;
+
+      // Extract assignees using shared helper
+      const requestedAssignees = extractAssignees(unassignItem);
+
+      core.info(`Requested assignees to unassign: ${JSON.stringify(requestedAssignees)}`);
+
+      // Use shared helper to filter, sanitize, dedupe, and limit
+      const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount, blockedAssignees);
+
+      if (uniqueAssignees.length === 0) {
+        core.info("No assignees to remove");
+        return {
+          success: true,
+          issueNumber: issueNumber,
+          assigneesRemoved: [],
+          message: "No valid assignees found",
+        };
+      }
+
+      // Resolve and validate target repository
+      const repoResult = resolveAndValidateRepo(unassignItem, defaultTargetRepo, allowedRepos, "issue");
+
+      if (!repoResult.success) {
+        core.warning(`Repository validation failed: ${repoResult.error}`);
+        return {
+          success: false,
+          error: repoResult.error,
+        };
+      }
+
+      const repoParts = repoResult.repoParts;
+      const targetRepo = repoResult.repo;
+
+      core.info(`Unassigning ${uniqueAssignees.length} users from issue #${issueNumber} in ${targetRepo}: ${JSON.stringify(uniqueAssignees)}`);
+
+      // If in staged mode, preview without executing
+      if (isStaged) {
+        logStagedPreviewInfo(`Would unassign users from issue #${issueNumber} in ${targetRepo}`);
+        return {
+          success: true,
+          staged: true,
+          previewInfo: {
+            issueNumber,
+            repo: targetRepo,
+            assignees: uniqueAssignees,
+          },
+        };
+      }
+
+      try {
+        // Remove assignees from the issue
+        await githubClient.rest.issues.removeAssignees({
+          owner: repoParts.owner,
+          repo: repoParts.repo,
+          issue_number: issueNumber,
+          assignees: uniqueAssignees,
+        });
 
-    core.info(`Unassigning ${uniqueAssignees.length} users from issue #${issueNumber} in ${targetRepo}: ${JSON.stringify(uniqueAssignees)}`);
+        core.info(`Successfully unassigned ${uniqueAssignees.length} user(s) from issue #${issueNumber} in ${targetRepo}`);
 
-    // If in staged mode, preview without executing
-    if (isStaged) {
-      logStagedPreviewInfo(`Would unassign users from issue #${issueNumber} in ${targetRepo}`);
-      return {
-        success: true,
-        staged: true,
-        previewInfo: {
-          issueNumber,
+        return {
+          success: true,
+          issueNumber: issueNumber,
           repo: targetRepo,
-          assignees: uniqueAssignees,
-        },
-      };
-    }
-
-    try {
-      // Remove assignees from the issue
-      await githubClient.rest.issues.removeAssignees({
-        owner: repoParts.owner,
-        repo: repoParts.repo,
-        issue_number: issueNumber,
-        assignees: uniqueAssignees,
-      });
-
-      core.info(`Successfully unassigned ${uniqueAssignees.length} user(s) from issue #${issueNumber} in ${targetRepo}`);
-
-      return {
-        success: true,
-        issueNumber: issueNumber,
-        repo: targetRepo,
-        assigneesRemoved: uniqueAssignees,
-      };
-    } catch (error) {
-      const errorMessage = getErrorMessage(error);
-      core.error(`Failed to unassign users: ${errorMessage}`);
-      return {
-        success: false,
-        error: errorMessage,
-      };
-    }
-  };
-}
+          assigneesRemoved: uniqueAssignees,
+        };
+      } catch (error) {
+        const errorMessage = getErrorMessage(error);
+        core.error(`Failed to unassign users: ${errorMessage}`);
+        return {
+          success: false,
+          error: errorMessage,
+        };
+      }
+    };
+  },
+});
 
 module.exports = { main };

From e717d428fe980a115769acd30b07535f6a15b2f0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 12 Apr 2026 14:12:10 +0000
Subject: [PATCH 3/4] fix: use || instead of ?? for maxCount default to match
 prior handler behavior

Addresses review feedback:
- Changed `config.max ?? 10` to `config.max || 10` so falsy values
  (0, empty string) fall back to 10, matching the original behavior
  of remove_labels and unassign_from_user handlers.
- Added test for max:0 edge case to lock down the intended behavior.

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/8df50aa0-f76d-43c2-8a78-f0aa03babd70

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 actions/setup/js/handler_scaffold.cjs      |  2 +-
 actions/setup/js/handler_scaffold.test.cjs | 12 ++++++++++++
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/actions/setup/js/handler_scaffold.cjs b/actions/setup/js/handler_scaffold.cjs
index ebf4579900e..3d946e4c11b 100644
--- a/actions/setup/js/handler_scaffold.cjs
+++ b/actions/setup/js/handler_scaffold.cjs
@@ -30,7 +30,7 @@
  */
 function createCountGatedHandler({ handlerType, setup }) {
   return async function main(config = {}) {
-    const maxCount = config.max ?? 10;
+    const maxCount = config.max || 10;
     let processedCount = 0;
 
     const handleItem = await setup(config, maxCount);
diff --git a/actions/setup/js/handler_scaffold.test.cjs b/actions/setup/js/handler_scaffold.test.cjs
index 4511541e96b..c77be53697e 100644
--- a/actions/setup/js/handler_scaffold.test.cjs
+++ b/actions/setup/js/handler_scaffold.test.cjs
@@ -49,6 +49,18 @@ describe("handler_scaffold", () => {
       expect(setupSpy).toHaveBeenCalledWith({}, 10);
     });
 
+    it("should fall back to 10 when max is 0", async () => {
+      const setupSpy = vi.fn().mockResolvedValue(async () => ({ success: true }));
+
+      const factory = createCountGatedHandler({
+        handlerType: "test_handler",
+        setup: setupSpy,
+      });
+
+      await factory({ max: 0 });
+      expect(setupSpy).toHaveBeenCalledWith({ max: 0 }, 10);
+    });
+
     it("should delegate to handleItem when under the limit", async () => {
       const handleItem = vi.fn().mockResolvedValue({ success: true, data: "result" });
 

From 445f69619f7ddc5083d666100af3fa886e78c627 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 12 Apr 2026 14:33:11 +0000
Subject: [PATCH 4/4] refactor: move isStagedMode into handler scaffold

Extracts `isStagedMode(config)` into the shared scaffold and passes
`isStaged` as a third parameter to each handler's `setup` function.
This removes duplicated isStagedMode imports and calls from all four
migrated handlers.

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2da6d7fe-083b-4f8a-a10d-c8f25674241d

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 actions/setup/js/add_labels.cjs            |  4 +---
 actions/setup/js/assign_to_user.cjs        |  7 ++-----
 actions/setup/js/handler_scaffold.cjs      | 10 +++++++---
 actions/setup/js/handler_scaffold.test.cjs | 20 +++++++++++++++++---
 actions/setup/js/remove_labels.cjs         |  6 +-----
 actions/setup/js/unassign_from_user.cjs    |  7 ++-----
 6 files changed, 30 insertions(+), 24 deletions(-)

diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs
index ea3c49e19e3..983bd050914 100644
--- a/actions/setup/js/add_labels.cjs
+++ b/actions/setup/js/add_labels.cjs
@@ -19,7 +19,6 @@ const { getErrorMessage } = require("./error_helpers.cjs");
 const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
 const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs");
 const { logStagedPreviewInfo } = require("./staged_preview.cjs");
-const { isStagedMode } = require("./safe_output_helpers.cjs");
 const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
 const { resolveRepoIssueTarget, loadTemporaryIdMapFromResolved } = require("./temporary_id.cjs");
 const { MAX_LABELS } = require("./constants.cjs");
@@ -32,11 +31,10 @@ const { createCountGatedHandler } = require("./handler_scaffold.cjs");
  */
 const main = createCountGatedHandler({
   handlerType: HANDLER_TYPE,
-  setup: async (config, maxCount) => {
+  setup: async (config, maxCount, isStaged) => {
     const { allowed: allowedLabels = [], blocked: blockedPatterns = [] } = config;
     const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
     const githubClient = await createAuthenticatedGitHubClient(config);
-    const isStaged = isStagedMode(config);
 
     core.info(`Add labels configuration: max=${maxCount}`);
     if (allowedLabels.length > 0) core.info(`Allowed labels: ${allowedLabels.join(", ")}`);
diff --git a/actions/setup/js/assign_to_user.cjs b/actions/setup/js/assign_to_user.cjs
index 473f2f25f2f..db8d0dad11f 100644
--- a/actions/setup/js/assign_to_user.cjs
+++ b/actions/setup/js/assign_to_user.cjs
@@ -8,7 +8,7 @@
 const { processItems } = require("./safe_output_processor.cjs");
 const { getErrorMessage } = require("./error_helpers.cjs");
 const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
-const { resolveIssueNumber, extractAssignees, isStagedMode } = require("./safe_output_helpers.cjs");
+const { resolveIssueNumber, extractAssignees } = require("./safe_output_helpers.cjs");
 const { logStagedPreviewInfo } = require("./staged_preview.cjs");
 const { parseBoolTemplatable } = require("./templatable.cjs");
 const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
@@ -24,7 +24,7 @@ const HANDLER_TYPE = "assign_to_user";
  */
 const main = createCountGatedHandler({
   handlerType: HANDLER_TYPE,
-  setup: async (config, maxCount) => {
+  setup: async (config, maxCount, isStaged) => {
     // Extract configuration
     const allowedAssignees = config.allowed ?? [];
     const blockedAssignees = config.blocked ?? [];
@@ -32,9 +32,6 @@ const main = createCountGatedHandler({
     const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
     const githubClient = await createAuthenticatedGitHubClient(config);
 
-    // Check if we're in staged mode
-    const isStaged = isStagedMode(config);
-
     core.info(`Assign to user configuration: max=${maxCount}, unassign_first=${unassignFirst}`);
     if (allowedAssignees.length > 0) {
       core.info(`Allowed assignees: ${allowedAssignees.join(", ")}`);
diff --git a/actions/setup/js/handler_scaffold.cjs b/actions/setup/js/handler_scaffold.cjs
index 3d946e4c11b..61e296f2bec 100644
--- a/actions/setup/js/handler_scaffold.cjs
+++ b/actions/setup/js/handler_scaffold.cjs
@@ -7,11 +7,13 @@
  * @typedef {import('./types/handler-factory').HandlerResult} HandlerResult
  */
 
+const { isStagedMode } = require("./safe_output_helpers.cjs");
+
 /**
  * @typedef {Object} CountGatedHandlerConfig
  * @property {string} handlerType - Handler type name used in log/warning messages (e.g., "add_labels")
- * @property {(config: Object, maxCount: number) => Promise<MessageHandlerFunction>} setup
- *   Setup function that receives the handler config and resolved maxCount,
+ * @property {(config: Object, maxCount: number, isStaged: boolean) => Promise<MessageHandlerFunction>} setup
+ *   Setup function that receives the handler config, resolved maxCount, and isStaged flag,
  *   performs handler-specific initialization (GitHub client, logging, etc.),
  *   and returns the message handler function for processing individual items.
  */
@@ -21,6 +23,7 @@
  *
  * This eliminates the duplicated scaffolding across safe-output handlers:
  * - Extracts maxCount from config (default 10)
+ * - Resolves staged mode from config
  * - Maintains processedCount in closure
  * - Gates each call with the max-count check
  * - Returns standardized error envelope when limit is reached
@@ -31,9 +34,10 @@
 function createCountGatedHandler({ handlerType, setup }) {
   return async function main(config = {}) {
     const maxCount = config.max || 10;
+    const isStaged = isStagedMode(config);
     let processedCount = 0;
 
-    const handleItem = await setup(config, maxCount);
+    const handleItem = await setup(config, maxCount, isStaged);
 
     /** @type {MessageHandlerFunction} */
     return async function handler(message, resolvedTemporaryIds) {
diff --git a/actions/setup/js/handler_scaffold.test.cjs b/actions/setup/js/handler_scaffold.test.cjs
index c77be53697e..6c446456f9d 100644
--- a/actions/setup/js/handler_scaffold.test.cjs
+++ b/actions/setup/js/handler_scaffold.test.cjs
@@ -34,7 +34,7 @@ describe("handler_scaffold", () => {
       const config = { max: 7, allowed: ["a", "b"] };
       await factory(config);
 
-      expect(setupSpy).toHaveBeenCalledWith(config, 7);
+      expect(setupSpy).toHaveBeenCalledWith(config, 7, false);
     });
 
     it("should default maxCount to 10 when not specified", async () => {
@@ -46,7 +46,7 @@ describe("handler_scaffold", () => {
       });
 
       await factory({});
-      expect(setupSpy).toHaveBeenCalledWith({}, 10);
+      expect(setupSpy).toHaveBeenCalledWith({}, 10, false);
     });
 
     it("should fall back to 10 when max is 0", async () => {
@@ -58,7 +58,21 @@ describe("handler_scaffold", () => {
       });
 
       await factory({ max: 0 });
-      expect(setupSpy).toHaveBeenCalledWith({ max: 0 }, 10);
+      expect(setupSpy).toHaveBeenCalledWith({ max: 0 }, 10, false);
+    });
+
+    it("should pass isStaged as true when config.staged is true", async () => {
+      const setupSpy = vi.fn().mockResolvedValue(async () => ({ success: true }));
+
+      const factory = createCountGatedHandler({
+        handlerType: "test_handler",
+        setup: setupSpy,
+      });
+
+      const config = { max: 5, staged: true };
+      await factory(config);
+
+      expect(setupSpy).toHaveBeenCalledWith(config, 5, true);
     });
 
     it("should delegate to handleItem when under the limit", async () => {
diff --git a/actions/setup/js/remove_labels.cjs b/actions/setup/js/remove_labels.cjs
index 1ad7f46ab4d..6bef6f9e4c7 100644
--- a/actions/setup/js/remove_labels.cjs
+++ b/actions/setup/js/remove_labels.cjs
@@ -12,7 +12,6 @@ const { validateLabels } = require("./safe_output_validator.cjs");
 const { getErrorMessage } = require("./error_helpers.cjs");
 const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
 const { logStagedPreviewInfo } = require("./staged_preview.cjs");
-const { isStagedMode } = require("./safe_output_helpers.cjs");
 const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
 const { resolveRepoIssueTarget, loadTemporaryIdMapFromResolved } = require("./temporary_id.cjs");
 const { createCountGatedHandler } = require("./handler_scaffold.cjs");
@@ -24,16 +23,13 @@ const { createCountGatedHandler } = require("./handler_scaffold.cjs");
  */
 const main = createCountGatedHandler({
   handlerType: HANDLER_TYPE,
-  setup: async (config, maxCount) => {
+  setup: async (config, maxCount, isStaged) => {
     // Extract configuration
     const allowedLabels = config.allowed || [];
     const blockedPatterns = config.blocked || [];
     const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
     const githubClient = await createAuthenticatedGitHubClient(config);
 
-    // Check if we're in staged mode
-    const isStaged = isStagedMode(config);
-
     core.info(`Remove labels configuration: max=${maxCount}`);
     if (allowedLabels.length > 0) {
       core.info(`Allowed labels to remove: ${allowedLabels.join(", ")}`);
diff --git a/actions/setup/js/unassign_from_user.cjs b/actions/setup/js/unassign_from_user.cjs
index 34b239a6aa8..3d129a5523c 100644
--- a/actions/setup/js/unassign_from_user.cjs
+++ b/actions/setup/js/unassign_from_user.cjs
@@ -8,7 +8,7 @@
 const { processItems } = require("./safe_output_processor.cjs");
 const { getErrorMessage } = require("./error_helpers.cjs");
 const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
-const { resolveIssueNumber, extractAssignees, isStagedMode } = require("./safe_output_helpers.cjs");
+const { resolveIssueNumber, extractAssignees } = require("./safe_output_helpers.cjs");
 const { logStagedPreviewInfo } = require("./staged_preview.cjs");
 const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
 const { createCountGatedHandler } = require("./handler_scaffold.cjs");
@@ -23,7 +23,7 @@ const HANDLER_TYPE = "unassign_from_user";
  */
 const main = createCountGatedHandler({
   handlerType: HANDLER_TYPE,
-  setup: async (config, maxCount) => {
+  setup: async (config, maxCount, isStaged) => {
     // Extract configuration
     const allowedAssignees = config.allowed || [];
     const blockedAssignees = config.blocked || [];
@@ -32,9 +32,6 @@ const main = createCountGatedHandler({
     const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
     const githubClient = await createAuthenticatedGitHubClient(config);
 
-    // Check if we're in staged mode
-    const isStaged = isStagedMode(config);
-
     core.info(`Unassign from user configuration: max=${maxCount}`);
     if (allowedAssignees.length > 0) {
       core.info(`Allowed assignees to unassign: ${allowedAssignees.join(", ")}`);
