From a5200af0b1639addf2d5185d4f371c058a0f7506 Mon Sep 17 00:00:00 2001 From: Avdhesh-Varshney <114330097+Avdhesh-Varshney@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:37:19 +0530 Subject: [PATCH 1/3] ai: ai-generated-code --- .github/workflows/auto-close-inactive-prs.yml | 83 +++ backend/.env.example | 6 + backend/Controllers/webhook.controller.js | 525 ++++++++++++++++++ backend/Routes/index.js | 2 + backend/Routes/webhook/github.routes.js | 24 + backend/Schemas/project.schema.js | 52 +- backend/package-lock.json | 82 ++- backend/package.json | 4 +- backend/server.js | 4 + backend/utils/cron.js | 33 ++ documentation/integration/WEBHOOK_SETUP.md | 120 ++++ 11 files changed, 927 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/auto-close-inactive-prs.yml create mode 100644 backend/Controllers/webhook.controller.js create mode 100644 backend/Routes/webhook/github.routes.js create mode 100644 backend/utils/cron.js create mode 100644 documentation/integration/WEBHOOK_SETUP.md diff --git a/.github/workflows/auto-close-inactive-prs.yml b/.github/workflows/auto-close-inactive-prs.yml new file mode 100644 index 000000000..310efd330 --- /dev/null +++ b/.github/workflows/auto-close-inactive-prs.yml @@ -0,0 +1,83 @@ +name: Auto Close Inactive PRs + +on: + schedule: + # Runs at 00:00 UTC every day + - cron: '0 0 * * *' + workflow_dispatch: # Allow manual triggering + +jobs: + auto-close: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Close inactive PRs + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const now = new Date(); + const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + + // Format date for GitHub API query + const formattedDate = twoWeeksAgo.toISOString().split('T')[0]; + + console.log(`Finding PRs older than ${formattedDate}...`); + + // Get open PRs that haven't been updated in 2 weeks + const { data: openPRs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'asc' + }); + + let closed = 0; + + // Process each PR + for (const pr of openPRs) { + const updatedAt = new Date(pr.updated_at); + + if (updatedAt < twoWeeksAgo) { + console.log(`PR #${pr.number} by ${pr.user.login} was last updated on ${updatedAt.toISOString().split('T')[0]}, closing...`); + + // Add comment to PR + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: `This PR is being automatically closed due to inactivity for more than 2 weeks. Please open a new PR if you wish to continue with this contribution. The associated draft project will also be removed from the database.` + }); + + // Close PR + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed' + }); + + // Trigger webhook cleanup for the database + try { + // You can use an HTTP client like axios or fetch to call your webhook cleanup endpoint + // This is commented out because it would require installing additional packages + // Instead we will rely on the daily cron job on your server + + // Example if you were to implement it: + // const axios = require('axios'); + // await axios.post('https://your-website.com/api/webhook/cleanup-drafts', { + // prNumber: pr.number, + // secret: process.env.WEBHOOK_SECRET + // }); + + closed++; + } catch (error) { + console.error(`Error triggering cleanup webhook for PR #${pr.number}:`, error); + } + } + } + + console.log(`Closed ${closed} inactive PRs`); diff --git a/backend/.env.example b/backend/.env.example index 392a0a125..baffd93bb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,8 +2,14 @@ PORT=8000 MONGODB_URL= SECRET_ACCESS_KEY= +# Cloudinary configuration CLOUDINARY_CLOUD_NAME= CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= VITE_SERVER_DOMAIN=https://blog-script-7kbo.onrender.com + +# GitHub webhook configuration +GITHUB_WEBHOOK_SECRET= +GITHUB_API_TOKEN= +CRON_SECRET= diff --git a/backend/Controllers/webhook.controller.js b/backend/Controllers/webhook.controller.js new file mode 100644 index 000000000..6a82db58c --- /dev/null +++ b/backend/Controllers/webhook.controller.js @@ -0,0 +1,525 @@ +import Project from "../Models/project.model.js"; +import User from "../Models/user.model.js"; +import crypto from 'crypto'; +import axios from 'axios'; + +// GitHub webhook secret (should be stored in environment variables in production) +const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET || 'your-webhook-secret'; +const GITHUB_API_TOKEN = process.env.GITHUB_API_TOKEN; + +/** + * Verify the webhook signature from GitHub + */ +const verifyGithubWebhook = (req) => { + const signature = req.headers['x-hub-signature-256']; + if (!signature) { + return false; + } + + const hmac = crypto.createHmac('sha256', GITHUB_WEBHOOK_SECRET); + const calculatedSignature = 'sha256=' + hmac.update(JSON.stringify(req.body)).digest('hex'); + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(calculatedSignature)); +}; + +/** + * Extract project ID from PR title + * Format: [PID-xxxxxxxx] Title of project + */ +const extractProjectId = (title) => { + const match = title.match(/\[(PID-[a-z0-9]+)\]/i); + return match ? match[1] : null; +}; + +/** + * Handle GitHub webhook events related to pull requests + */ +export const githubWebhookHandler = async (req, res) => { + try { + // Verify webhook signature + if (!verifyGithubWebhook(req)) { + return res.status(401).json({ error: "Invalid webhook signature" }); + } + + const event = req.headers['x-github-event']; + const payload = req.body; + + // Handle only pull request events + if (event !== 'pull_request') { + return res.status(200).json({ message: "Event ignored" }); + } + + const { + action, + pull_request: { + number, + title, + html_url, + user: { login }, + merged, + state, + created_at, + merged_at, + base: { repo: { full_name } } + } + } = payload; + + // Extract project ID from PR title + const projectId = extractProjectId(title); + if (!projectId && action !== 'edited') { + console.log(`No project ID found in PR title: "${title}"`); + return res.status(200).json({ message: "No project ID found in PR title" }); + } + + // Find the user by GitHub username + const user = await User.findOne({ username: login }); + + if (!user) { + return res.status(404).json({ error: `User with GitHub username ${login} not found` }); + } + + // Get the repository name and owner from full_name (owner/repo) + const [owner, repo] = full_name.split('/'); + + // Handle different PR actions + switch (action) { + case 'opened': + // PR opened with project ID in title + if (projectId) { + await handlePROpened(number, projectId, user._id, owner, repo); + } + break; + + case 'edited': + // Check if title was changed and now contains a project ID + if (payload.changes && payload.changes.title) { + const oldTitle = payload.changes.title.from; + const oldProjectId = extractProjectId(oldTitle); + + if (projectId && projectId !== oldProjectId) { + // Title was changed and now contains a different or new project ID + await handlePRTitleChanged(number, projectId, oldProjectId, user._id, owner, repo); + } + } + break; + + case 'closed': + if (merged) { + // PR merged + await handlePRMerged(number, projectId, user._id, owner, repo, merged_at); + } else { + // PR closed without merging + await handlePRClosed(number, projectId, user._id, owner, repo); + } + break; + + case 'reopened': + // PR reopened + if (projectId) { + await handlePRReopened(number, projectId, user._id, owner, repo); + } + break; + + default: + // Ignore other PR actions + break; + } + + return res.status(200).json({ message: "Webhook processed successfully" }); + } catch (error) { + console.error("Webhook error:", error); + return res.status(500).json({ error: "Internal server error" }); + } +}; + +/** + * Handle when a PR title is changed and now contains a project ID + */ +const handlePRTitleChanged = async (prNumber, newProjectId, oldProjectId, userId, owner, repo) => { + try { + // If the PR was previously linked to a different project, unlink it + if (oldProjectId) { + const oldProject = await Project.findOne({ project_id: oldProjectId }); + if (oldProject) { + await Project.findByIdAndUpdate(oldProject._id, { + $pull: { pr: { number: prNumber } } + }); + console.log(`Unlinked PR #${prNumber} from project ${oldProjectId}`); + } + } + + // Link to the new project ID + const project = await Project.findOne({ project_id: newProjectId }); + if (!project) { + console.log(`No project found with ID ${newProjectId} for PR #${prNumber}`); + return; + } + + // Check if this PR is already linked to this project + const prExists = project.pr.some(pr => pr.number === prNumber); + if (prExists) { + console.log(`PR #${prNumber} is already linked to project ${newProjectId}`); + return; + } + + // Update the project with PR information + await Project.findByIdAndUpdate(project._id, { + $push: { + pr: { + number: prNumber, + status: 'pending', + raisedBy: userId, + createdAt: new Date() + } + } + }); + + console.log(`PR #${prNumber} linked to project ${newProjectId} after title change`); + } catch (error) { + console.error(`Error handling PR title change:`, error); + } +}; + +/** + * Handle when a PR is opened + */ +const handlePROpened = async (prNumber, projectId, userId, owner, repo) => { + try { + // Find project with matching project_id + const project = await Project.findOne({ + project_id: projectId, + draft: true + }); + + if (!project) { + console.log(`No draft project found with ID ${projectId} for PR #${prNumber}`); + return; + } + + // Update the project with PR information + await Project.findByIdAndUpdate(project._id, { + $push: { + pr: { + number: prNumber, + status: 'pending', + raisedBy: userId, + createdAt: new Date() + } + } + }); + + console.log(`PR #${prNumber} linked to project ${project.title} (${projectId})`); + } catch (error) { + console.error(`Error handling PR opened:`, error); + } +}; + +/** + * Handle when a PR is merged + */ +const handlePRMerged = async (prNumber, projectId, userId, owner, repo, mergedAt) => { + try { + // Find project with matching project_id and PR number + const project = await Project.findOne({ + 'pr.number': prNumber, + project_id: projectId + }); + + if (!project) { + console.log(`No project found with ID ${projectId} for PR #${prNumber}`); + return; + } + + // Update PR status to merged and set mergedAt date + // Note: We don't automatically set draft=false because publishing is optional + await Project.findByIdAndUpdate(project._id, { + $set: { + 'pr.$[elem].status': 'merged', + 'pr.$[elem].mergedAt': new Date(mergedAt) + } + }, { + arrayFilters: [{ 'elem.number': prNumber }] + }); + + console.log(`PR #${prNumber} marked as merged for project ${project.title}`); + } catch (error) { + console.error(`Error handling PR merged:`, error); + } +}; + +/** + * Handle when a PR is closed without merging + */ +const handlePRClosed = async (prNumber, projectId, userId, owner, repo) => { + try { + // Find project with matching project_id and PR number + const project = await Project.findOne({ + 'pr.number': prNumber, + project_id: projectId + }); + + if (!project) { + console.log(`No project found with ID ${projectId} for PR #${prNumber}`); + return; + } + + // Update PR status to rejected + await Project.findByIdAndUpdate(project._id, { + $set: { + 'pr.$[elem].status': 'rejected' + } + }, { + arrayFilters: [{ 'elem.number': prNumber }] + }); + + console.log(`PR #${prNumber} marked as rejected for project ${project.title}`); + } catch (error) { + console.error(`Error handling PR closed:`, error); + } +}; + +/** + * Handle when a PR is reopened + */ +const handlePRReopened = async (prNumber, projectId, userId, owner, repo) => { + try { + // Find project with matching project_id and PR number + const project = await Project.findOne({ + 'pr.number': prNumber, + project_id: projectId + }); + + if (!project) { + console.log(`No project found with ID ${projectId} for PR #${prNumber}`); + return; + } + + // Update PR status back to pending + await Project.findByIdAndUpdate(project._id, { + $set: { + 'pr.$[elem].status': 'pending' + } + }, { + arrayFilters: [{ 'elem.number': prNumber }] + }); + + console.log(`PR #${prNumber} marked as pending for project ${project.title}`); + } catch (error) { + console.error(`Error handling PR reopened:`, error); + } +}; + +/** + * Clean up old draft projects + * This should be called by a scheduled job/cron + */ +export const cleanupOldDraftProjects = async (req, res) => { + try { + const twoWeeksAgo = new Date(); + twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); + + // Find all draft projects with PRs older than 2 weeks that are still pending or rejected + const oldDraftProjects = await Project.find({ + draft: true, + 'pr.createdAt': { $lt: twoWeeksAgo }, + 'pr.status': { $in: ['pending', 'rejected'] } + }); + + const projectIds = oldDraftProjects.map(project => project._id); + + // Delete the old draft projects + const result = await Project.deleteMany({ + _id: { $in: projectIds } + }); + + return res.status(200).json({ + message: `Cleaned up ${result.deletedCount} old draft projects`, + projectIds + }); + } catch (error) { + console.error("Error cleaning up old draft projects:", error); + return res.status(500).json({ error: "Internal server error" }); + } +}; + +/** + * Approve a PR from the website + * This can be called by reviewers from the website + */ +export const approvePR = async (req, res) => { + try { + const { projectId, prNumber, reviewerId, isAdmin = false } = req.body; + + if (!projectId || !prNumber || !reviewerId) { + return res.status(400).json({ error: "Missing required parameters" }); + } + + // Find the project + const project = await Project.findOne({ project_id: projectId }); + if (!project) { + return res.status(404).json({ error: `Project with ID ${projectId} not found` }); + } + + // Find the PR in the project + const prIndex = project.pr.findIndex(pr => pr.number === parseInt(prNumber, 10)); + if (prIndex === -1) { + return res.status(404).json({ error: `PR #${prNumber} not found in project ${projectId}` }); + } + + // Find the reviewer + const reviewer = await User.findById(reviewerId); + if (!reviewer) { + return res.status(404).json({ error: `Reviewer not found` }); + } + + // Update the PR with the approval + const pr = project.pr[prIndex]; + + // If the PR doesn't have reviewers array, add it + if (!pr.reviewers) { + pr.reviewers = []; + } + + // Check if reviewer has already approved + const reviewerIndex = pr.reviewers.findIndex(r => r.reviewer.toString() === reviewerId); + if (reviewerIndex !== -1) { + // Update existing approval + pr.reviewers[reviewerIndex].approved = true; + pr.reviewers[reviewerIndex].updatedAt = new Date(); + } else { + // Add new approval + pr.reviewers.push({ + reviewer: reviewerId, + approved: true, + createdAt: new Date(), + updatedAt: new Date() + }); + } + + // Save the project with updated PR information + await project.save(); + + // If admin override or all reviewers (at least 2-3) have approved, merge the PR + const totalApprovals = pr.reviewers.filter(r => r.approved).length; + const shouldMerge = isAdmin || totalApprovals >= 2; // Requiring at least 2 approvals + + if (shouldMerge) { + // Update PR status to approved + project.pr[prIndex].status = 'approved'; + await project.save(); + + // Merge the PR on GitHub if it's not already merged + if (pr.status !== 'merged' && shouldMerge) { + await mergePR(prNumber, project.repository); + } + + return res.status(200).json({ + message: `PR #${prNumber} approved and will be merged`, + merged: true, + project + }); + } + + return res.status(200).json({ + message: `PR #${prNumber} approved by ${reviewer.fullname || reviewer.username}`, + merged: false, + approvalsCount: totalApprovals, + project + }); + + } catch (error) { + console.error("Error approving PR:", error); + return res.status(500).json({ error: "Internal server error" }); + } +}; + +/** + * Merge a PR on GitHub + */ +const mergePR = async (prNumber, repositoryUrl) => { + try { + // Extract owner and repo from repository URL + // Format: https://github.com/owner/repo + const urlParts = repositoryUrl.split('/'); + const owner = urlParts[urlParts.length - 2]; + const repo = urlParts[urlParts.length - 1]; + + if (!GITHUB_API_TOKEN) { + console.error('GITHUB_API_TOKEN not configured'); + return; + } + + // GitHub API endpoint for merging PR + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/merge`; + + // Make API request to merge PR + const response = await axios.put( + apiUrl, + { + commit_title: `Merge PR #${prNumber}`, + commit_message: `Approved via website review process`, + merge_method: 'merge' + }, + { + headers: { + Authorization: `Bearer ${GITHUB_API_TOKEN}`, + Accept: 'application/vnd.github.v3+json' + } + } + ); + + console.log(`PR #${prNumber} successfully merged`, response.data); + return true; + } catch (error) { + console.error(`Error merging PR #${prNumber}:`, error.response?.data || error.message); + return false; + } +}; + +/** + * Publish a project (make it non-draft) + * This can be called by the contributor to publish their project + */ +export const publishProject = async (req, res) => { + try { + const { projectId, userId } = req.body; + + if (!projectId || !userId) { + return res.status(400).json({ error: "Missing required parameters" }); + } + + // Find the project + const project = await Project.findOne({ + project_id: projectId, + author: userId, + draft: true + }); + + if (!project) { + return res.status(404).json({ + error: `Draft project with ID ${projectId} not found or you are not the author` + }); + } + + // Check if at least one PR is merged + const hasMergedPR = project.pr.some(pr => pr.status === 'merged'); + + if (!hasMergedPR) { + return res.status(400).json({ + error: `Cannot publish project because no PR has been merged yet` + }); + } + + // Publish the project + project.draft = false; + await project.save(); + + return res.status(200).json({ + message: `Project ${projectId} has been published successfully`, + project + }); + + } catch (error) { + console.error("Error publishing project:", error); + return res.status(500).json({ error: "Internal server error" }); + } +}; diff --git a/backend/Routes/index.js b/backend/Routes/index.js index 26d587b14..2c7e56f51 100644 --- a/backend/Routes/index.js +++ b/backend/Routes/index.js @@ -2,11 +2,13 @@ import express from 'express'; import authRoutes from './api/auth.routes.js'; import mediaRoutes from './api/media.routes.js'; import projectRoutes from './api/project.routes.js'; +import githubWebhookRoutes from './webhook/github.routes.js'; const router = express.Router(); router.use('/auth', authRoutes); router.use('/media', mediaRoutes); router.use('/project', projectRoutes); +router.use('/webhook', githubWebhookRoutes); export default router; diff --git a/backend/Routes/webhook/github.routes.js b/backend/Routes/webhook/github.routes.js new file mode 100644 index 000000000..308b668cc --- /dev/null +++ b/backend/Routes/webhook/github.routes.js @@ -0,0 +1,24 @@ +import express from 'express'; +import { + githubWebhookHandler, + cleanupOldDraftProjects, + approvePR, + publishProject +} from '../../Controllers/webhook.controller.js'; +import { authenticateUser } from '../../Middlewares/auth.middleware.js'; + +const githubWebhookRoutes = express.Router(); + +// Handle GitHub webhook events +githubWebhookRoutes.post('/github', githubWebhookHandler); + +// Endpoint to trigger cleanup of old draft projects (can be triggered by a cron job) +githubWebhookRoutes.post('/cleanup-drafts', cleanupOldDraftProjects); + +// Endpoint for approving PRs from the website +githubWebhookRoutes.post('/approve-pr', authenticateUser, approvePR); + +// Endpoint for publishing a project after PR approval +githubWebhookRoutes.post('/publish-project', authenticateUser, publishProject); + +export default githubWebhookRoutes; diff --git a/backend/Schemas/project.schema.js b/backend/Schemas/project.schema.js index ca992c38e..a5bfe66a5 100644 --- a/backend/Schemas/project.schema.js +++ b/backend/Schemas/project.schema.js @@ -6,6 +6,9 @@ const projectSchema = Schema( type: String, required: true, unique: true, + default: function () { + return `PID-${Date.now().toString(36)}`; + } }, title: { type: String, @@ -67,7 +70,54 @@ const projectSchema = Schema( draft: { type: Boolean, default: false - } + }, + pr: [ + { + number: { + type: Number, + unique: true, + required: true + }, + status: { + type: String, + enum: ["pending", "approved", "merged", "rejected"], + default: "pending" + }, + raisedBy: { + type: Schema.Types.ObjectId, + ref: 'users' + }, + createdAt: { + type: Date, + default: Date.now + }, + mergedAt: { + type: Date, + default: null + }, + reviewers: [ + { + reviewer: { + type: Schema.Types.ObjectId, + ref: 'users', + required: true + }, + approved: { + type: Boolean, + default: false + }, + createdAt: { + type: Date, + default: Date.now + }, + updatedAt: { + type: Date, + default: Date.now + } + } + ] + } + ] }, { timestamps: { diff --git a/backend/package-lock.json b/backend/package-lock.json index 7a36baeb5..3801e3f5b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "axios": "^1.6.8", "bcrypt": "^5.1.1", "cloudinary": "^2.5.1", "cors": "^2.8.5", @@ -18,7 +19,8 @@ "jsonwebtoken": "^9.0.2", "mongoose": "^8.11.0", "multer": "^1.4.5-lts.1", - "nanoid": "^5.1.2" + "nanoid": "^5.1.2", + "node-cron": "^3.0.3" }, "devDependencies": { "nodemon": "^3.1.9" @@ -700,8 +702,33 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz", + "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==", "license": "MIT", - "optional": true + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } }, "node_modules/balanced-match": { "version": "1.0.2", @@ -993,7 +1020,6 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", - "optional": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -1135,7 +1161,6 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=0.4.0" } @@ -1288,7 +1313,6 @@ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", - "optional": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -1519,6 +1543,26 @@ "@google-cloud/storage": "^7.14.0" } }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz", @@ -1972,7 +2016,6 @@ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", - "optional": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -2702,6 +2745,27 @@ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "license": "MIT" }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -2982,6 +3046,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/backend/package.json b/backend/package.json index ff0c4b6ab..224d92d4f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "license": "ISC", "description": "", "dependencies": { + "axios": "^1.6.8", "bcrypt": "^5.1.1", "cloudinary": "^2.5.1", "cors": "^2.8.5", @@ -22,7 +23,8 @@ "jsonwebtoken": "^9.0.2", "mongoose": "^8.11.0", "multer": "^1.4.5-lts.1", - "nanoid": "^5.1.2" + "nanoid": "^5.1.2", + "node-cron": "^3.0.3" }, "devDependencies": { "nodemon": "^3.1.9" diff --git a/backend/server.js b/backend/server.js index 55b9ac5e5..392ff8fbc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,6 +4,7 @@ import dotenv from "dotenv"; import connectDB from "./config/db.js"; import router from "./Routes/index.js"; +import { initCronJobs } from "./utils/cron.js"; dotenv.config(); @@ -21,4 +22,7 @@ connectDB(); server.get("/", (req, res) => res.send("Backend is running...")); server.use("/api", router); +// Initialize cron jobs +initCronJobs(server); + server.listen(PORT, () => console.log(`Server running on port ${PORT}`)); diff --git a/backend/utils/cron.js b/backend/utils/cron.js new file mode 100644 index 000000000..2d836c2bf --- /dev/null +++ b/backend/utils/cron.js @@ -0,0 +1,33 @@ +import cron from 'node-cron'; +import axios from 'axios'; + +/** + * Initialize cron jobs for the application + * @param {Object} app - Express app instance + */ +export const initCronJobs = (app) => { + // Create a function to call the cleanup endpoint + const cleanupOldDrafts = async () => { + try { + console.log('Running scheduled cleanup of old draft projects...'); + + // Get server's base URL from config, or construct it + const baseUrl = process.env.VITE_SERVER_DOMAIN || `http://localhost:${process.env.PORT || 8000}`; + + // Make request to cleanup endpoint + const response = await axios.post(`${baseUrl}/api/webhook/cleanup-drafts`, { + // Add any authentication or validation as needed + secret: process.env.CRON_SECRET + }); + + console.log('Cleanup complete:', response.data); + } catch (error) { + console.error('Error in draft projects cleanup cron:', error); + } + }; + + // Schedule cleanup job to run daily at midnight + cron.schedule('0 0 * * *', cleanupOldDrafts); + + console.log('Cron jobs initialized successfully'); +}; diff --git a/documentation/integration/WEBHOOK_SETUP.md b/documentation/integration/WEBHOOK_SETUP.md new file mode 100644 index 000000000..e0e723e9e --- /dev/null +++ b/documentation/integration/WEBHOOK_SETUP.md @@ -0,0 +1,120 @@ +# GitHub Webhook Setup for Project Integration + +This document explains how to set up GitHub webhooks to integrate with our project workflow system. + +## Overview + +Our workflow integrates GitHub Pull Requests with the project blog system: + +1. Contributors draft a project on the website +2. Contributors get their project ID from the website +3. Contributors raise a PR and include the project ID in the PR title: `[PID-xxxxxxxx] Title of project` +4. PR is automatically linked to project draft through webhook +5. Website reviewers approve the PR directly on the website +6. Once approved by enough reviewers (or an admin), the PR is automatically merged +7. After merging, contributors can choose to publish their project + +## Setup Instructions + +### 1. Generate API Tokens + +You need two tokens for this integration: + +1. **Webhook Secret**: Used to verify webhook payloads + ```bash + # On Linux/Mac + openssl rand -hex 20 + ``` + +2. **GitHub API Token**: Create a Personal Access Token with `repo` scope to allow merging PRs + - Go to GitHub → Settings → Developer Settings → Personal Access Tokens + - Generate a new token with `repo` scope + +### 2. Configure Environment Variables + +Add the following variables to your `.env` file: + +``` +GITHUB_WEBHOOK_SECRET=your_generated_secret +GITHUB_API_TOKEN=your_github_personal_access_token +VITE_SERVER_DOMAIN=https://your-production-server.com +CRON_SECRET=another_random_string +``` + +### 3. Set Up the GitHub Webhook + +1. Go to your GitHub repository +2. Click on "Settings" → "Webhooks" → "Add webhook" +3. Configure the webhook: + - Payload URL: `https://your-production-server.com/api/webhook/github` + - Content type: `application/json` + - Secret: Enter the same secret you used for `GITHUB_WEBHOOK_SECRET` + - Events: Select "Pull requests" events only + - Active: Check this box + +4. Click "Add webhook" + +### 4. Test the Webhook + +1. Create a draft project on the website +2. Note the project ID (format: PID-xxxxxxxx) +3. Create a test PR with title format: `[PID-xxxxxxxx] Your project title` +4. Check your server logs to verify that the webhook is receiving events +5. Verify in the database that the PR is now linked to your project + +## Workflow Details + +### Project Creation Flow + +1. Contributors create a draft project on the website +2. They get their unique project ID (PID-xxxxxxxx) +3. They raise a PR with the ID in the title: `[PID-xxxxxxxx] Project Title` +4. The webhook automatically links the PR to their draft project + +### PR Title Format + +The PR title must include the project ID in square brackets: + +``` +[PID-xxxxxxxx] Your descriptive PR title +``` + +If the PR is created without the project ID, it will not be linked until the title is edited to include it. + +### PR Review Process on Website + +1. Reviewers can approve PRs directly on the website +2. A minimum of 2-3 approvals are required for automatic merging +3. Admins can override and force-approve a PR +4. After approval, the PR is automatically merged on GitHub + +### Publication Process + +1. After PR is merged, the contributors decide whether to publish their project +2. Publishing is optional and controlled by the contributors +3. Contributors can publish through the website interface + +### Multiple PRs and Contributors + +- Multiple PRs can be linked to a single project +- Multiple contributors can be associated with a project +- Each PR event updates the project status accordingly + +## API Endpoints + +Your application provides the following webhook-related endpoints: + +- `POST /api/webhook/github` - Receives GitHub webhook events +- `POST /api/webhook/cleanup-drafts` - Cleans up old drafts (triggered by cron) +- `POST /api/webhook/approve-pr` - Endpoint for approving PRs from the website +- `POST /api/webhook/publish-project` - Endpoint for publishing approved projects + +## Troubleshooting + +- Check server logs for webhook events +- Verify the webhook secret matches in GitHub and your server +- Ensure your server is accessible from GitHub +- For PR linking issues, check that the project ID format in the PR title is correct +- For approval issues, ensure reviewers have proper permissions + +For any issues, contact the system administrator. \ No newline at end of file From a615e1fb67d4189907a6b8da0652da1d81e74c94 Mon Sep 17 00:00:00 2001 From: Avdhesh-Varshney <114330097+Avdhesh-Varshney@users.noreply.github.com> Date: Fri, 14 Mar 2025 16:49:29 +0530 Subject: [PATCH 2/3] workflow 48 hrs triggered setup --- .github/workflows/auto-close-inactive-prs.yml | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/.github/workflows/auto-close-inactive-prs.yml b/.github/workflows/auto-close-inactive-prs.yml index 310efd330..16c5b070f 100644 --- a/.github/workflows/auto-close-inactive-prs.yml +++ b/.github/workflows/auto-close-inactive-prs.yml @@ -2,9 +2,8 @@ name: Auto Close Inactive PRs on: schedule: - # Runs at 00:00 UTC every day - - cron: '0 0 * * *' - workflow_dispatch: # Allow manual triggering + - cron: '0 0 */2 * *' + workflow_dispatch: jobs: auto-close: @@ -59,25 +58,20 @@ jobs: pull_number: pr.number, state: 'closed' }); - + // Trigger webhook cleanup for the database try { - // You can use an HTTP client like axios or fetch to call your webhook cleanup endpoint - // This is commented out because it would require installing additional packages - // Instead we will rely on the daily cron job on your server - - // Example if you were to implement it: - // const axios = require('axios'); - // await axios.post('https://your-website.com/api/webhook/cleanup-drafts', { - // prNumber: pr.number, - // secret: process.env.WEBHOOK_SECRET - // }); - + const axios = require('axios'); + await axios.post('https://blog-script-1-1cc6.onrender.com/api/webhook/cleanup-drafts', { + prNumber: pr.number, + secret: process.env.WEBHOOK_SECRET + }); + closed++; } catch (error) { console.error(`Error triggering cleanup webhook for PR #${pr.number}:`, error); } } } - + console.log(`Closed ${closed} inactive PRs`); From fb102a35fa42bc32716d423ee3be1b7817432e46 Mon Sep 17 00:00:00 2001 From: Avdhesh-Varshney <114330097+Avdhesh-Varshney@users.noreply.github.com> Date: Sat, 15 Mar 2025 13:23:28 +0530 Subject: [PATCH 3/3] setup project id --- backend/Schemas/project.schema.js | 5 +---- frontend/src/components/ProjectEditor.jsx | 18 +++++++++++++++++- frontend/src/pages/Editor.jsx | 1 + 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/backend/Schemas/project.schema.js b/backend/Schemas/project.schema.js index a5bfe66a5..40b5221f6 100644 --- a/backend/Schemas/project.schema.js +++ b/backend/Schemas/project.schema.js @@ -5,10 +5,7 @@ const projectSchema = Schema( project_id: { type: String, required: true, - unique: true, - default: function () { - return `PID-${Date.now().toString(36)}`; - } + unique: true }, title: { type: String, diff --git a/frontend/src/components/ProjectEditor.jsx b/frontend/src/components/ProjectEditor.jsx index 3ada5412c..ea74166bd 100644 --- a/frontend/src/components/ProjectEditor.jsx +++ b/frontend/src/components/ProjectEditor.jsx @@ -14,12 +14,17 @@ const defaultBanner = "https://res.cloudinary.com/avdhesh-varshney/image/upload/ const ProjectEditor = () => { - let { project, project: { title, banner, repository, projectUrl, content, tags, des }, setProject, textEditor, setTextEditor, setEditorState } = useContext(EditorContext); + let { project, project: { projectId, title, banner, repository, projectUrl, content, tags, des }, setProject, textEditor, setTextEditor, setEditorState } = useContext(EditorContext); let { userAuth: { access_token } } = useContext(UserContext); let navigate = useNavigate(); + const handleCopy = () => { + navigator.clipboard.writeText(projectId); + toast.success("Project ID copied to clipboard"); + }; + useEffect(() => { if (!textEditor.isReady) { setTextEditor(new EditorJS({ @@ -181,6 +186,17 @@ const ProjectEditor = () => {
+
+ Project ID: + {projectId} + +
+