Skip to content

sanitize_content_core.cjs: incomplete invisible-char strip set and missing detection gate for MCP-driven safe-output writes #4040

sanitize_content_core.cjs: incomplete invisible-char strip set and missing detection gate for MCP-driven safe-output writes

sanitize_content_core.cjs: incomplete invisible-char strip set and missing detection gate for MCP-driven safe-output writes #4040

name: Auto-Close Parent Issues
# Trigger when any issue is closed
on:
issues:
types: [closed]
permissions:
issues: write
jobs:
close-parent-issues:
runs-on: ubuntu-latest
steps:
- name: Auto-close parent issues when all sub-issues are closed
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const { owner, repo } = context.repo;
const closedIssueNumber = context.payload.issue.number;
core.info('=================================================');
core.info('Auto-Close Parent Issues Workflow');
core.info('=================================================');
core.info(`Triggered by: Issue #${closedIssueNumber} was closed`);
core.info(`Repository: ${owner}/${repo}`);
core.info(`Event: ${context.eventName}`);
core.info('');
/**
* Get the full issue details including parent relationships using GraphQL
* Uses pagination to handle issues with many sub-issues (e.g., 1000+)
*/
async function getIssueWithRelationships(issueNumber) {
core.info(`📊 Fetching issue #${issueNumber} with relationship data...`);
// Fetch parent issues (trackedInIssues) - usually small number
const parentQuery = `
query($owner: String!, $repo: String!, $issueNumber: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
id
number
title
state
stateReason
trackedInIssues(first: 10) {
nodes {
id
number
title
state
stateReason
}
}
}
}
}
`;
try {
const result = await github.graphql(parentQuery, {
owner,
repo,
issueNumber
});
const issue = result.repository.issue;
// Now fetch all sub-issues with pagination
core.info(`📄 Fetching sub-issues with pagination...`);
const allSubIssues = await fetchAllSubIssues(issue.id);
// Add sub-issues to the issue object
issue.trackedIssues = {
nodes: allSubIssues
};
core.info(`✓ Fetched issue #${issue.number}: "${issue.title}"`);
core.info(` State: ${issue.state} (${issue.stateReason || 'N/A'})`);
core.info(` Parent issues: ${issue.trackedInIssues.nodes.length}`);
core.info(` Sub-issues: ${issue.trackedIssues.nodes.length}`);
return issue;
} catch (error) {
core.error(`❌ Failed to fetch issue #${issueNumber}: ${error.message}`);
throw error;
}
}
/**
* Fetch all sub-issues using pagination to handle large numbers (e.g., 1000+)
*/
async function fetchAllSubIssues(issueId) {
const allSubIssues = [];
let hasNextPage = true;
let cursor = null;
let pageCount = 0;
const query = `
query($issueId: ID!, $cursor: String) {
node(id: $issueId) {
... on Issue {
trackedIssues(first: 100, after: $cursor) {
nodes {
id
number
title
state
stateReason
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`;
while (hasNextPage) {
pageCount++;
core.info(` Fetching page ${pageCount} of sub-issues...`);
const result = await github.graphql(query, {
issueId,
cursor
});
const trackedIssues = result.node.trackedIssues;
allSubIssues.push(...trackedIssues.nodes);
hasNextPage = trackedIssues.pageInfo.hasNextPage;
cursor = trackedIssues.pageInfo.endCursor;
core.info(` Retrieved ${trackedIssues.nodes.length} sub-issues (total so far: ${allSubIssues.length})`);
// Safety check to prevent infinite loops
if (pageCount > 50) {
core.warning(`⚠️ Reached maximum page limit (50 pages, 5000 sub-issues). Some sub-issues may not be processed.`);
break;
}
}
core.info(`✓ Total sub-issues fetched: ${allSubIssues.length}`);
return allSubIssues;
}
/**
* Check if all sub-issues of a parent are closed
*/
function areAllSubIssuesClosed(parentIssue) {
const subIssues = parentIssue.trackedIssues.nodes;
core.info(`🔍 Checking sub-issues of #${parentIssue.number} "${parentIssue.title}"...`);
core.info(` Total sub-issues: ${subIssues.length}`);
if (subIssues.length === 0) {
core.info(` ⚠️ Issue #${parentIssue.number} has no sub-issues`);
return false;
}
const openSubIssues = [];
const closedSubIssues = [];
for (const subIssue of subIssues) {
if (subIssue.state === 'OPEN') {
openSubIssues.push(subIssue);
core.info(` - #${subIssue.number}: "${subIssue.title}" [OPEN]`);
} else {
closedSubIssues.push(subIssue);
core.info(` - #${subIssue.number}: "${subIssue.title}" [CLOSED]`);
}
}
core.info(` Summary: ${closedSubIssues.length} closed, ${openSubIssues.length} open`);
return openSubIssues.length === 0;
}
/**
* Close a parent issue
*/
async function closeIssue(issueNumber, reason) {
core.info(`🔒 Closing issue #${issueNumber}...`);
core.info(` Reason: ${reason}`);
try {
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'completed'
});
core.info(`✓ Successfully closed issue #${issueNumber}`);
// Add a comment explaining why it was closed
const comment = `🎉 **Automatically closed**\n\nAll sub-issues have been completed. This parent issue is now being closed automatically.\n\n${reason}`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: comment
});
core.info(`✓ Added closure comment to issue #${issueNumber}`);
return true;
} catch (error) {
core.error(`❌ Failed to close issue #${issueNumber}: ${error.message}`);
return false;
}
}
/**
* Process a parent issue and recursively check its parents
*/
async function processParentIssue(parentIssue, depth = 0) {
const indent = ' '.repeat(depth);
core.info('');
core.info(`${indent}${'='.repeat(50)}`);
core.info(`${indent}Processing Parent Issue (Depth: ${depth})`);
core.info(`${indent}${'='.repeat(50)}`);
core.info(`${indent}Issue: #${parentIssue.number} "${parentIssue.title}"`);
core.info(`${indent}Current State: ${parentIssue.state}`);
// If already closed, skip
if (parentIssue.state === 'CLOSED') {
core.info(`${indent}⏭️ Issue #${parentIssue.number} is already closed, skipping`);
return;
}
// Check if all sub-issues are closed
if (areAllSubIssuesClosed(parentIssue)) {
core.info(`${indent}✅ All sub-issues of #${parentIssue.number} are closed!`);
// Close the parent issue
const reason = `Triggered by cascade from issue #${closedIssueNumber} at depth ${depth}`;
const closed = await closeIssue(parentIssue.number, reason);
if (closed) {
// Now check if this issue has parents and process them recursively
core.info(`${indent}🔼 Looking for parent issues of #${parentIssue.number}...`);
// Fetch fresh data to get the parent relationships
const updatedIssue = await getIssueWithRelationships(parentIssue.number);
const grandParents = updatedIssue.trackedInIssues.nodes;
if (grandParents.length > 0) {
core.info(`${indent}📋 Found ${grandParents.length} parent issue(s) to check`);
for (const grandParent of grandParents) {
core.info(`${indent}🔼 Walking up to parent #${grandParent.number}`);
// Fetch full details for the grandparent
const grandParentFull = await getIssueWithRelationships(grandParent.number);
// Recursively process the grandparent
await processParentIssue(grandParentFull, depth + 1);
}
} else {
core.info(`${indent}🏁 No parent issues found. Reached top of the tree.`);
}
}
} else {
core.info(`${indent}⏸️ Not all sub-issues of #${parentIssue.number} are closed yet`);
core.info(`${indent} This issue will remain open until all sub-issues are completed`);
}
}
/**
* Main execution
*/
async function main() {
try {
core.info('🚀 Starting parent issue closure check...');
core.info('');
// Get the closed issue with its relationships
const closedIssue = await getIssueWithRelationships(closedIssueNumber);
// Get parent issues (issues that track this one)
const parentIssues = closedIssue.trackedInIssues.nodes;
if (parentIssues.length === 0) {
core.info('ℹ️ This issue has no parent issues');
core.info('✓ Nothing to do');
return;
}
core.info('');
core.info(`🔍 Found ${parentIssues.length} parent issue(s) to check:`);
parentIssues.forEach(parent => {
core.info(` - #${parent.number}: "${parent.title}" [${parent.state}]`);
});
core.info('');
// Process each parent issue
for (const parentIssue of parentIssues) {
// Fetch full details for the parent including its sub-issues
const parentFull = await getIssueWithRelationships(parentIssue.number);
// Process this parent (and recursively its parents)
await processParentIssue(parentFull, 0);
}
core.info('');
core.info('=================================================');
core.info('✅ Workflow completed successfully');
core.info('=================================================');
} catch (error) {
core.error('');
core.error('=================================================');
core.error('❌ Workflow failed with error');
core.error('=================================================');
core.error(`Error: ${error.message}`);
if (error.stack) {
core.error(`Stack trace: ${error.stack}`);
}
throw error;
}
}
// Run the main function
await main();