Skip to content

Enhanced Multi-Purpose Bot #130

Enhanced Multi-Purpose Bot

Enhanced Multi-Purpose Bot #130

Workflow file for this run

name: Enhanced Multi-Purpose Bot
on:
pull_request:
types: [opened, synchronize, reopened, closed]
issues:
types: [opened, edited, closed]
issue_comment:
types: [created, edited]
push:
branches: [main]
schedule:
# Runs every day at midnight UTC
- cron: '0 0 * * *'
jobs:
bot:
runs-on: ubuntu-latest
strategy:
# python-version matrix is not strictly needed for this job, but can be kept for consistency or future use.
matrix:
python-version: ["3.8"]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
# This step handles most event-driven bot functionalities
- name: Multi-Function Bot Core
# This step is triggered by pull_request, issues, and issue_comment events
if: github.event_name == 'pull_request' || github.event_name == 'issues' || github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
// The 'github' and 'context' objects are automatically provided by actions/github-script
/**
* Welcomes new contributors with a personalized message on their first issue or PR.
* Triggered on 'issues' or 'pull_request' opened events.
*
* @returns {Promise<void>}
*/
async function welcomeNewContributor() {
// Ensure the event is an 'opened' event for issues or pull requests
if (context.payload.action !== 'opened') {
console.log("Not an opened event, skipping welcome message.");
return;
}
// Only welcome human users
if (github.event.sender.type !== 'User') {
console.log("Sender is not a user, skipping welcome message.");
return;
}
const actor = context.actor; // The user who triggered the event
console.log(`Checking if ${actor} is a first-time contributor...`);
try {
// List all pull requests created by the actor
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all', // Include open and closed PRs
creator: actor,
});
// List all issues created by the actor
const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all', // Include open and closed issues
creator: actor,
});
// Combine and filter unique contributions (issues + PRs).
// Sometimes a single action (like opening a PR) might trigger both issue and PR events briefly.
// A more robust check is to see if the count of *previous* contributions is zero.
// Since the current event already created one, we check if the total count is exactly 1.
const totalContributions = pulls.length + issues.length;
// Check if this is the very first contribution by this user to this repo
// If the current event is the first one they've ever created, the count should be 1.
// We need to be careful if the user already has contributions.
// Let's refine: count how many issues/PRs were created *before* this one.
// This requires iterating through the fetched list and checking creation dates, which is complex.
// A simpler, generally reliable approach for initial welcoming is to check if the total count (including the current one) is low.
// If totalContributions is 1, it's highly likely this is their first. If it's > 1, they've contributed before.
// Let's re-evaluate the original check: pulls.length + issues.length === 0
// This check would be correct IF the list API only returned contributions *before* the current event.
// But it typically returns *all* contributions by the user.
// So, if this is truly their first contribution, the list APIs will return data with length 1 (for the current event).
// If they had previous contributions, the length will be > 1.
// Therefore, the original logic `pulls.length + issues.length === 0` is actually INCORRECT for welcoming the FIRST contribution.
// The correct logic should be to check if they have any *previous* contributions.
// The most straightforward way using the list API is to see if the total count returned is 1.
// If the count is 1, this event is likely their first ever issue/PR in this repo.
const isFirstContribution = totalContributions === 1;
console.log(`Total contributions by ${actor}: ${totalContributions}`);
if (isFirstContribution) {
const message = `🎉 Welcome to the project, @${actor}! 🎉\n\nThank you so much for your first contribution! We really appreciate you taking the time to contribute. We will review your ${github.event_name === 'pull_request' ? 'pull request' : 'issue'} as soon as possible.`;
// Ensure we have an issue_number to comment on
if (context.issue && context.issue.number) {
console.log(`Sending welcome message to issue/PR #${context.issue.number}`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message,
});
console.log("Welcome message sent.");
} else {
console.warn("Could not determine issue number for welcome message.");
}
} else {
console.log(`${actor} has contributed before, skipping welcome message.`);
}
} catch (error) {
console.error("Error checking contribution history or welcoming new contributor:", error);
// Continue execution even if welcoming fails
}
}
/**
* Labels issues and PRs based on keywords found in their titles.
* Triggered on 'issues' or 'pull_request' opened or edited events.
*
* @returns {Promise<void>}
*/
async function labelIssuesAndPRs() {
// Only run on opened or edited events for issues or pull requests
if (context.payload.action !== 'opened' && context.payload.action !== 'edited') {
console.log("Not an opened or edited event for issue/PR, skipping labeling.");
return;
}
try {
const eventPayload = context.payload.pull_request || context.payload.issue;
if (!eventPayload) {
console.warn("Could not find issue or pull request payload, skipping labeling.");
return;
}
const title = eventPayload.title;
const issueNumber = context.issue.number;
if (!title || !issueNumber) {
console.warn("Could not determine title or issue number, skipping labeling.");
return;
}
console.log(`Labeling issue/PR #${issueNumber} with title: "${title}"`);
let labels = [];
if (title.toLowerCase().includes('bug')) {
labels.push('bug');
}
if (title.toLowerCase().includes('feature')) {
labels.push('feature');
}
if (title.toLowerCase().includes('documentation')) {
labels.push('documentation');
}
if (title.toLowerCase().includes('question')) {
labels.push('question'); // Added a common label
}
if (labels.length > 0) {
console.log(`Adding labels: ${labels.join(', ')}`);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labels,
});
console.log("Labels added.");
} else {
console.log("No matching keywords found in title, no labels added.");
}
} catch (error) {
console.error("Error labeling issue/PR:", error);
// Continue execution even if labeling fails
}
}
/**
* Comments on a PR when it is closed (merged or not).
* Triggered on 'pull_request' closed event.
*
* @returns {Promise<void>}
*/
async function commentWhenPRClosed() {
// Only run on pull_request closed event
if (github.event_name !== 'pull_request' || context.payload.action !== 'closed') {
console.log("Not a pull_request closed event, skipping PR closed comment.");
return;
}
try {
const merged = context.payload.pull_request.merged;
const commentBody = merged ? "🎉 This PR has been successfully merged! 🎉" : "This PR has been closed without merging.";
const issueNumber = context.issue.number;
if (!issueNumber) {
console.warn("Could not determine issue number for PR closed comment.");
return;
}
console.log(`Commenting on closed PR #${issueNumber}: ${commentBody}`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: commentBody,
});
console.log("PR closed comment sent.");
} catch (error) {
console.error("Error commenting on PR closure:", error);
// Continue execution even if commenting fails
}
}
/**
* Reacts to specific comments on issues/PRs with a thumbs up emoji.
* Triggered on 'issue_comment' created or edited events.
*
* @returns {Promise<void>}
*/
async function reactToIssueComments() {
// Only run on issue_comment created or edited event
if (github.event_name !== 'issue_comment' || (context.payload.action !== 'created' && context.payload.action !== 'edited')) {
console.log("Not an issue_comment created or edited event, skipping comment reaction.");
return;
}
try {
const commentBody = context.payload.comment.body;
const commentId = context.payload.comment.id;
if (!commentBody || !commentId) {
console.warn("Could not determine comment body or ID, skipping comment reaction.");
return;
}
console.log(`Checking comment #${commentId} for reaction keywords.`);
// Use a regular expression for more flexible matching, ignoring case and punctuation
const positiveKeywords = /\b(good job|nice work|great job)\b/i;
if (positiveKeywords.test(commentBody)) {
console.log(`Found positive keyword in comment #${commentId}, adding reaction.`);
// React with a thumbs up
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
content: '+1', // Thumbs up
});
console.log("Reaction added.");
} else {
console.log(`No positive keywords found in comment #${commentId}.`);
}
} catch (error) {
console.error("Error reacting to issue comment:", error);
// Continue execution even if reaction fails
}
}
// --- Main execution logic for event-driven functions ---
try {
console.log(`Event triggered: ${github.event_name}, action: ${context.payload.action}`);
if (github.event_name === 'pull_request' || github.event_name === 'issues') {
await welcomeNewContributor();
await labelIssuesAndPRs();
// commentWhenPRClosed is called separately because it only applies to PR closed events
}
if (github.event_name === 'pull_request' && context.payload.action === 'closed') {
await commentWhenPRClosed();
}
if (github.event_name === 'issue_comment') {
await reactToIssueComments();
}
} catch (error) {
console.error("Unhandled error during bot core execution:", error);
// Re-throw the error to fail the action, helps in debugging workflow issues
throw error;
}
# This step handles the scheduled task for closing inactive issues
- name: Close Inactive Issues (Scheduled)
if: github.event_name == 'schedule'
uses: actions/github-script@v7
with:
script: |
// github and context objects are automatically provided by actions/github-script
const DAYS_BEFORE_WARN = 23; // Days before adding a warning comment
const DAYS_BEFORE_CLOSE = 30; // Days before closing the issue
const WARNING_COMMENT_MARKER = '<!-- bot: inactive-issue-warning -->'; // Marker to avoid duplicate warnings
const EXCLUDE_LABELS = ['pinned', 'wontfix', 'priority:high']; // Labels to exclude from closing
/**
* Gets open issues that haven't been updated recently.
* @returns {Promise<Array<Object>>} An array of inactive issue objects.
*/
async function getInactiveIssues() {
console.log("Fetching open issues...");
try {
// Fetch open issues, sorted by updated time ascending
const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'asc',
per_page: 100, // Fetch up to 100 issues per page
// Add more pages if needed, but 100 is often sufficient for typical repos
});
console.log(`Found ${issues.length} open issues.`);
const now = new Date();
const issuesToProcess = issues.filter(issue => {
// Exclude pull requests
if (issue.pull_request) {
return false;
}
// Exclude issues with specific labels
const hasExcludedLabel = issue.labels.some(label =>
typeof label === 'string' ? EXCLUDE_LABELS.includes(label.toLowerCase()) : EXCLUDE_LABELS.includes(label.name.toLowerCase())
);
if (hasExcludedLabel) {
console.log(`Skipping issue #${issue.number} due to excluded label.`);
return false;
}
const updatedAt = new Date(issue.updated_at);
const diffTime = now.getTime() - updatedAt.getTime();
const days = Math.floor(diffTime / (1000 * 3600 * 24)); // Use floor for strictness
// Include issues that are either due for a warning or due for closing
return days >= DAYS_BEFORE_WARN;
});
console.log(`Found ${issuesToProcess.length} issues potentially due for processing.`);
return issuesToProcess;
} catch (error) {
console.error("Error listing issues:", error);
return []; // Return empty array on error
}
}
/**
* Checks if a warning comment has already been added to an issue.
* @param {number} issueNumber - The issue number.
* @returns {Promise<boolean>} True if a warning comment with the marker exists, false otherwise.
*/
async function hasWarningComment(issueNumber) {
try {
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100, // Fetch recent comments
});
return comments.some(comment => comment.body && comment.body.includes(WARNING_COMMENT_MARKER));
} catch (error) {
console.error(`Error checking for warning comment on issue #${issueNumber}:`, error);
return false; // Assume no warning comment on error
}
}
/**
* Adds a warning comment to an issue.
* @param {Object} issue - The issue object.
* @returns {Promise<void>}
*/
async function addWarningComment(issue) {
const issueNumber = issue.number;
console.log(`Adding warning comment to issue #${issueNumber}...`);
try {
const message = `This issue has been inactive for ${DAYS_BEFORE_WARN} days. It will be automatically closed in ${DAYS_BEFORE_CLOSE - DAYS_BEFORE_WARN} days if no further activity occurs.\n\n${WARNING_COMMENT_MARKER}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: message,
});
console.log(`Warning comment added to issue #${issueNumber}.`);
} catch (error) {
console.error(`Error adding warning comment to issue #${issueNumber}:`, error);
}
}
/**
* Closes a given issue and adds a closing comment.
* @param {Object} issue - The issue object.
* @returns {Promise<void>}
*/
async function closeIssue(issue) {
const issueNumber = issue.number;
console.log(`Closing issue #${issueNumber}...`);
try {
const message = `This issue has been automatically closed due to inactivity after ${DAYS_BEFORE_CLOSE} days. If you believe this issue is still relevant, please reopen it or create a new one.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: message,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
reason: 'not_planned', // or 'completed' if appropriate, 'not_planned' is common for inactivity
});
console.log(`Successfully closed issue #${issueNumber}.`);
} catch (error) {
console.error(`Error closing issue #${issueNumber}:`, error);
}
}
// --- Main execution logic for scheduled tasks ---
async function mainScheduledTask() {
console.log("Starting scheduled task: Close Inactive Issues.");
const issuesToProcess = await getInactiveIssues();
if (issuesToProcess.length === 0) {
console.log('No issues found that are due for warning or closing.');
return;
}
console.log(`Processing ${issuesToProcess.length} issues...`);
const now = new Date();
for (const issue of issuesToProcess) {
const updatedAt = new Date(issue.updated_at);
const diffTime = now.getTime() - updatedAt.getTime();
const days = Math.floor(diffTime / (1000 * 3600 * 24));
if (days >= DAYS_BEFORE_CLOSE) {
// Issue is old enough to be closed
console.log(`Issue #${issue.number} is ${days} days old, due for closing.`);
await closeIssue(issue);
} else if (days >= DAYS_BEFORE_WARN) {
// Issue is old enough for a warning, check if warning already exists
const warned = await hasWarningComment(issue.number);
if (!warned) {
console.log(`Issue #${issue.number} is ${days} days old, due for warning.`);
await addWarningComment(issue);
} else {
console.log(`Issue #${issue.number} is ${days} days old, warning already exists.`);
}
}
}
console.log("Finished processing inactive issues.");
}
// Execute the appropriate main function based on the event
if (github.event_name === 'schedule') {
await mainScheduledTask();
} else {
// The first github-script step handles other events,
// but this structure allows potential future logic specific to other events here.
console.log(`This step is not configured for event: ${github.event_name}. Skipping.`);
}