Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
284 changes: 283 additions & 1 deletion .github/workflows/agentics-maintenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,286 @@ jobs:
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
${getMaintenanceScript()}
// @ts-check
/// <reference types="@actions/github-script" />

/**
* Maximum number of discussions to update per run
*/
const MAX_UPDATES_PER_RUN = 100;

/**
* Delay between GraphQL API calls in milliseconds to avoid rate limiting
*/
const GRAPHQL_DELAY_MS = 500;

/**
* Delay execution for a specified number of milliseconds
* @param {number} ms - Milliseconds to delay
* @returns {Promise<void>}
*/
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

/**
* Search for open discussions with expiration markers
* @param {any} github - GitHub GraphQL instance
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @returns {Promise<Array<{id: string, number: number, title: string, url: string, body: string, createdAt: string}>>} Matching discussions
*/
async function searchDiscussionsWithExpiration(github, owner, repo) {
const discussions = [];
let hasNextPage = true;
let cursor = null;

while (hasNextPage) {
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
discussions(first: 100, after: $cursor, states: [OPEN]) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
number
title
url
body
createdAt
}
}
}
}
`;

const result = await github.graphql(query, {
owner: owner,
repo: repo,
cursor: cursor,
});

if (!result || !result.repository || !result.repository.discussions) {
break;
}

const nodes = result.repository.discussions.nodes || [];

// Filter for discussions with agentic workflow markers and expiration comments
for (const discussion of nodes) {
// Check if created by an agentic workflow (body contains "> AI generated by" at start of line)
const agenticPattern = /^> AI generated by/m;
const isAgenticWorkflow = discussion.body && agenticPattern.test(discussion.body);

if (!isAgenticWorkflow) {
continue;
}

// Check if has expiration marker
const expirationPattern = /<!-- gh-aw-expires: ([^>]+) -->/;
const match = discussion.body ? discussion.body.match(expirationPattern) : null;

if (match) {
discussions.push(discussion);
}
}

hasNextPage = result.repository.discussions.pageInfo.hasNextPage;
cursor = result.repository.discussions.pageInfo.endCursor;
}

return discussions;
}

/**
* Extract expiration date from discussion body
* @param {string} body - Discussion body
* @returns {Date|null} Expiration date or null if not found/invalid
*/
function extractExpirationDate(body) {
const expirationPattern = /<!-- gh-aw-expires: ([^>]+) -->/;
const match = body.match(expirationPattern);

if (!match) {
return null;
}

const expirationISO = match[1].trim();
const expirationDate = new Date(expirationISO);

// Validate the date
if (isNaN(expirationDate.getTime())) {
return null;
}

return expirationDate;
}

/**
* Validate discussion creation date
* @param {string} createdAt - ISO 8601 creation date
* @returns {boolean} True if valid
*/
function validateCreationDate(createdAt) {
const creationDate = new Date(createdAt);
return !isNaN(creationDate.getTime());
}

/**
* Add comment to a GitHub Discussion using GraphQL
* @param {any} github - GitHub GraphQL instance
* @param {string} discussionId - Discussion node ID
* @param {string} message - Comment body
* @returns {Promise<{id: string, url: string}>} Comment details
*/
async function addDiscussionComment(github, discussionId, message) {
const result = await github.graphql(
`
mutation($dId: ID!, $body: String!) {
addDiscussionComment(input: { discussionId: $dId, body: $body }) {
comment {
id
url
}
}
}`,
{ dId: discussionId, body: message }
);

return result.addDiscussionComment.comment;
}

/**
* Close a GitHub Discussion as OUTDATED using GraphQL
* @param {any} github - GitHub GraphQL instance
* @param {string} discussionId - Discussion node ID
* @returns {Promise<{id: string, url: string}>} Discussion details
*/
async function closeDiscussionAsOutdated(github, discussionId) {
const result = await github.graphql(
`
mutation($dId: ID!) {
closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
discussion {
id
url
}
}
}`,
{ dId: discussionId }
);

return result.closeDiscussion.discussion;
}

async function main() {
const owner = context.repo.owner;
const repo = context.repo.repo;

core.info(`Searching for expired discussions in ${owner}/${repo}`);

// Search for discussions with expiration markers
const discussionsWithExpiration = await searchDiscussionsWithExpiration(github, owner, repo);

if (discussionsWithExpiration.length === 0) {
core.info("No discussions with expiration markers found");
return;
}

core.info(`Found ${discussionsWithExpiration.length} discussion(s) with expiration markers`);

// Check which discussions are expired
const now = new Date();
const expiredDiscussions = [];

for (const discussion of discussionsWithExpiration) {
// Validate creation date
if (!validateCreationDate(discussion.createdAt)) {
core.warning(`Discussion #${discussion.number} has invalid creation date, skipping`);
continue;
}

// Extract and validate expiration date
const expirationDate = extractExpirationDate(discussion.body);
if (!expirationDate) {
core.warning(`Discussion #${discussion.number} has invalid expiration date, skipping`);
continue;
}

// Check if expired
if (now >= expirationDate) {
expiredDiscussions.push({
...discussion,
expirationDate: expirationDate,
});
}
}

if (expiredDiscussions.length === 0) {
core.info("No expired discussions found");
return;
}

core.info(`Found ${expiredDiscussions.length} expired discussion(s)`);

// Limit to MAX_UPDATES_PER_RUN
const discussionsToClose = expiredDiscussions.slice(0, MAX_UPDATES_PER_RUN);

if (expiredDiscussions.length > MAX_UPDATES_PER_RUN) {
core.warning(`Found ${expiredDiscussions.length} expired discussions, but only closing the first ${MAX_UPDATES_PER_RUN}`);
}

let closedCount = 0;
const closedDiscussions = [];

for (let i = 0; i < discussionsToClose.length; i++) {
const discussion = discussionsToClose[i];

try {
const closingMessage = `This discussion was automatically closed because it expired on ${discussion.expirationDate.toISOString()}.`;

// Add comment first
core.info(`Adding closing comment to discussion #${discussion.number}`);
await addDiscussionComment(github, discussion.id, closingMessage);

// Then close the discussion as outdated
core.info(`Closing discussion #${discussion.number} as outdated`);
await closeDiscussionAsOutdated(github, discussion.id);

closedDiscussions.push({
number: discussion.number,
url: discussion.url,
title: discussion.title,
});

closedCount++;
core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`);
} catch (error) {
core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`);
// Continue with other discussions even if one fails
}

// Add delay between GraphQL operations to avoid rate limiting (except for the last item)
if (i < discussionsToClose.length - 1) {
await delay(GRAPHQL_DELAY_MS);
}
}

// Write summary
if (closedCount > 0) {
let summaryContent = `## Closed Expired Discussions\n\n`;
summaryContent += `Closed **${closedCount}** expired discussion(s):\n\n`;
for (const closed of closedDiscussions) {
summaryContent += `- Discussion #${closed.number}: [${closed.title}](${closed.url})\n`;
}
await core.summary.addRaw(summaryContent).write();
}

core.info(`Successfully closed ${closedCount} expired discussion(s)`);
}

await main();

Loading