From 4d1b9d9af6670ad222629f5f64f08475e181f1c3 Mon Sep 17 00:00:00 2001 From: vansh-09 Date: Wed, 3 Jun 2026 22:43:36 +0530 Subject: [PATCH] ci(github): overhaul CI/CD workflows for PR and issue management (#734) --- .github/workflows/auto-assign.yml | 87 ++++++++ .github/workflows/auto-file-labels.yml | 24 --- .github/workflows/auto-label-issues.yml | 26 --- .github/workflows/auto-label-prs.yml | 76 ------- .github/workflows/difficulty.yml | 131 ++++++++++++ .github/workflows/issue-triage.yml | 48 +++++ .github/workflows/pr-auto-assign.yml | 80 ++++++++ .github/workflows/pr-label-inheritance.yml | 107 ++++++++++ .github/workflows/pr-reviewer.yml | 37 ++++ .github/workflows/quality-labeller.yml | 202 +++++++++++++++++++ .github/workflows/stale-assignees.yml | 58 ++++++ .github/workflows/type-labeller.yml | 223 +++++++++++++++++++++ 12 files changed, 973 insertions(+), 126 deletions(-) create mode 100644 .github/workflows/auto-assign.yml delete mode 100644 .github/workflows/auto-file-labels.yml delete mode 100644 .github/workflows/auto-label-issues.yml delete mode 100644 .github/workflows/auto-label-prs.yml create mode 100644 .github/workflows/difficulty.yml create mode 100644 .github/workflows/issue-triage.yml create mode 100644 .github/workflows/pr-auto-assign.yml create mode 100644 .github/workflows/pr-label-inheritance.yml create mode 100644 .github/workflows/pr-reviewer.yml create mode 100644 .github/workflows/quality-labeller.yml create mode 100644 .github/workflows/stale-assignees.yml create mode 100644 .github/workflows/type-labeller.yml diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..825830d --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,87 @@ +name: Auto Assign Issue + +on: + issue_comment: + types: [created] + +permissions: + issues: write + pull-requests: write + +jobs: + auto-assign: + runs-on: ubuntu-latest + steps: + - name: Auto-assign issue to commenter + uses: actions/github-script@v7 + with: + script: | + const commentBody = context.payload.comment.body.trim().toLowerCase(); + + // List of trigger phrases/keywords + const triggerKeywords = [ + 'take', + 'assign', + '.take', + '/take', + '/assign', + 'i want to work on this', + 'i would like to work on this' + ]; + + // Check if comment matches any trigger keyword or starts with one + const isTrigger = triggerKeywords.some(keyword => { + return commentBody === keyword || commentBody.startsWith(keyword + ' ') || commentBody.startsWith(keyword + '\n'); + }); + + if (!isTrigger) { + console.log("Comment does not trigger auto-assignment."); + return; + } + + const issue = context.payload.issue; + + // Skip pull requests + if (issue.pull_request) { + console.log("This comment is on a pull request, skipping auto-assign."); + return; + } + + const commenter = context.payload.comment.user.login; + + // Check if the issue is already assigned + if (issue.assignees && issue.assignees.length > 0) { + const currentAssignees = issue.assignees.map(a => a.login); + if (currentAssignees.includes(commenter)) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `Hi @${commenter}, you are already assigned to this issue! Please proceed with your contribution. 😊` + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `Hi @${commenter}, this issue is already assigned to @${currentAssignees.join(', @')}. Please look for other unassigned issues to contribute to! Thank you.` + }); + } + return; + } + + // Assign the issue to the commenter + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: [commenter] + }); + + // Post confirmation comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `Hi @${commenter}, this issue has been successfully assigned to you! 🎉\n\nMake sure to read our [Contributing Guidelines](CONTRIBUTING.md) and submit your PR within the GSSoC timeframe. Happy coding! 🚀` + }); diff --git a/.github/workflows/auto-file-labels.yml b/.github/workflows/auto-file-labels.yml deleted file mode 100644 index d5f6c07..0000000 --- a/.github/workflows/auto-file-labels.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Label PRs Based on Files - -on: - pull_request_target: - types: - - opened - - synchronize - - reopened - -permissions: - contents: read - pull-requests: write - issues: write - -jobs: - labeler: - runs-on: ubuntu-latest - - steps: - - name: Label PRs automatically - uses: actions/labeler@v5 - - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" \ No newline at end of file diff --git a/.github/workflows/auto-label-issues.yml b/.github/workflows/auto-label-issues.yml deleted file mode 100644 index 67ed479..0000000 --- a/.github/workflows/auto-label-issues.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Auto Label Issues - -on: - issues: - types: - - opened - -permissions: - issues: write - -jobs: - label-issues: - runs-on: ubuntu-latest - - steps: - - name: Add default labels to new issue - uses: actions/github-script@v7 - - with: - script: | - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.issue.number, - labels: ['gssoc-2026'] - }); \ No newline at end of file diff --git a/.github/workflows/auto-label-prs.yml b/.github/workflows/auto-label-prs.yml deleted file mode 100644 index 283f356..0000000 --- a/.github/workflows/auto-label-prs.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Auto Label Pull Requests - -on: - pull_request_target: - types: - - opened - - reopened - - synchronize - -permissions: - pull-requests: write - issues: write - -jobs: - auto-label-prs: - runs-on: ubuntu-latest - - steps: - - name: Apply PR labels - uses: actions/github-script@v7 - - with: - script: | - const pr = context.payload.pull_request; - const branch = pr.head.ref; - - let labels = ['gssoc-2026']; - - // Difficulty labels - if (branch.startsWith('level1/')) { - labels.push('level:beginner'); - } - - if (branch.startsWith('level2/')) { - labels.push('level:intermediate'); - } - - if (branch.startsWith('level3/')) { - labels.push('level:advanced'); - } - - if (branch.startsWith('level4/')) { - labels.push('level:critical'); - } - - // Type labels - if (branch.includes('feat/')) { - labels.push('type:feature'); - } - - if (branch.includes('fix/')) { - labels.push('type:bug'); - } - - if (branch.includes('docs/')) { - labels.push('type:docs'); - } - - if (branch.includes('refactor/')) { - labels.push('type:refactor'); - } - - if (branch.includes('test/')) { - labels.push('type:testing'); - } - - if (branch.includes('style/')) { - labels.push('type:design'); - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels - }); \ No newline at end of file diff --git a/.github/workflows/difficulty.yml b/.github/workflows/difficulty.yml new file mode 100644 index 0000000..efa4631 --- /dev/null +++ b/.github/workflows/difficulty.yml @@ -0,0 +1,131 @@ +name: PR Difficulty Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, edited] + +permissions: + issues: write + pull-requests: write + +jobs: + detect-difficulty: + name: Assign difficulty label + runs-on: ubuntu-latest + + steps: + - name: Analyze PR and assign difficulty label + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const prNum = pr.number; + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + + // ── Label catalogue ──────────────────────────────────────────── + const DIFFICULTY_LABELS = ['level:beginner', 'level:intermediate', 'level:advanced', 'level:critical']; + + const LABEL_META = { + 'level:beginner': { color: '0E8A16', description: 'Small, low-risk change (≤3 files, ≤50 lines)' }, + 'level:intermediate': { color: 'FBCA04', description: 'Medium-sized feature or fix (≤10 files, ≤250 lines)' }, + 'level:advanced': { color: 'D93F0B', description: 'Large feature or broad change (≤25 files, ≤800 lines)' }, + 'level:critical': { color: 'B60205', description: 'Major / core-repository change' }, + }; + + const CORE_PATTERNS = [ + /^\.github\/workflows\//, + /package(-lock)?\.json$/, + /^src\/App\.(js|jsx|ts|tsx)$/, + /^src\/main\.(js|jsx|ts|tsx)$/, + /^src\/config\//, + /^server\/server\.js$/, + /^server\/routes\//, + /^server\/middleware\//, + ]; + + const ALWAYS_CRITICAL_PATTERNS = [ + /^src\/main\.(js|jsx|ts|tsx)$/, + /^server\/server\.js$/, + /^server\/routes\//, + ]; + + // ── Helpers ──────────────────────────────────────────────────── + async function ensureLabels() { + await Promise.all( + Object.entries(LABEL_META).map(async ([name, { color, description }]) => { + try { + await github.rest.issues.getLabel({ ...repo, name }); + } catch (err) { + if (err.status !== 404) throw err; + await github.rest.issues.createLabel({ ...repo, name, color, description }); + console.log(`✅ Created label: ${name}`); + } + }) + ); + } + + async function getFiles() { + return github.paginate(github.rest.pulls.listFiles, { + ...repo, + pull_number: prNum, + per_page: 100, + }); + } + + async function getCurrentLabels() { + const { data } = await github.rest.issues.get({ ...repo, issue_number: prNum }); + return data.labels.map(l => (typeof l === 'string' ? l : l.name)); + } + + function classify({ filesChanged, linesChanged, hasCoreChanges, hasAlwaysCritical }) { + if (hasAlwaysCritical) return 'level:critical'; + if (filesChanged > 25 || linesChanged > 800) return 'level:critical'; + if (hasCoreChanges && (filesChanged > 10 || linesChanged > 300)) + return 'level:critical'; + if (filesChanged <= 3 && linesChanged <= 50) return 'level:beginner'; + if (filesChanged <= 10 && linesChanged <= 250) return 'level:intermediate'; + return 'level:advanced'; + } + + async function applyLabel(targetLabel) { + const current = await getCurrentLabels(); + + const staleLabels = current.filter(l => DIFFICULTY_LABELS.includes(l) && l !== targetLabel); + + await Promise.all( + staleLabels.map(name => + github.rest.issues.removeLabel({ ...repo, issue_number: prNum, name }) + .then(() => console.log(`🗑 Removed stale difficulty label: ${name}`)) + ) + ); + + if (current.includes(targetLabel) && staleLabels.length === 0) { + console.log(`✔ Difficulty label already correct: ${targetLabel} — skipping.`); + return; + } + + if (!current.includes(targetLabel)) { + await github.rest.issues.addLabels({ ...repo, issue_number: prNum, labels: [targetLabel] }); + console.log(`🏷 Applied difficulty label: ${targetLabel}`); + } + } + + // ── Main ─────────────────────────────────────────────────────── + const files = await getFiles(); + + const additions = files.reduce((s, f) => s + f.additions, 0); + const deletions = files.reduce((s, f) => s + f.deletions, 0); + const linesChanged = additions + deletions; + const filesChanged = files.length; + const hasCoreChanges = files.some(f => CORE_PATTERNS.some(p => p.test(f.filename))); + const hasAlwaysCritical = files.some(f => ALWAYS_CRITICAL_PATTERNS.some(p => p.test(f.filename))); + + const targetLabel = classify({ filesChanged, linesChanged, hasCoreChanges, hasAlwaysCritical }); + + console.log(`PR #${prNum} — "${pr.title}"`); + console.log(` Files : ${filesChanged} | Lines: +${additions} -${deletions} = ${linesChanged}`); + console.log(` Core changes: ${hasCoreChanges} | Always-critical paths: ${hasAlwaysCritical}`); + console.log(` → Difficulty: ${targetLabel}`); + + await ensureLabels(); + await applyLabel(targetLabel); diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 0000000..efd9e0a --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,48 @@ +name: Auto Label Issues + +on: + issues: + types: [opened] + +permissions: + issues: write + +jobs: + label-issue: + runs-on: ubuntu-latest + steps: + - name: Auto Label Issue based on Content + uses: actions/github-script@v7 + with: + script: | + const title = context.payload.issue.title.toLowerCase(); + const body = (context.payload.issue.body || "").toLowerCase(); + const labelsToAdd = ['gssoc']; // Always tag with core gssoc + + // Dynamic classification rules matching the GSSoC label styles + const rules = { + 'type:bug': ['bug', 'error', 'fail', 'broken', 'crash', 'issue', 'not working', 'prevent', 'fix cors', 'cors'], + 'type:feature': ['feature', 'add', 'implement', 'create', 'new functional', 'enhance'], + 'type:docs': ['doc', 'readme', 'guide', 'documentation', 'comment', 'instruction'], + 'type:design': ['design', 'css', 'ui', 'ux', 'visual', 'color', 'style', 'layout', 'responsive', 'align'], + 'type:testing': ['test', 'coverage', 'jest', 'vitest', 'cypress', 'unit test', 'integration'], + 'type:refactor': ['refactor', 'clean', 'structure', 'modular', 'cleanup'], + 'type:performance': ['perf', 'speed', 'optimization', 'slow', 'latency', 'fast'], + 'type:security': ['security', 'auth', 'leak', 'trivy', 'gitleaks', 'vulnerability'] + }; + + for (const [label, keywords] of Object.entries(rules)) { + const matches = keywords.some(kw => title.includes(kw) || body.includes(kw)); + if (matches) { + labelsToAdd.push(label); + } + } + + console.log(`Adding labels to issue #${context.payload.issue.number}:`, labelsToAdd); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + labels: labelsToAdd + }); diff --git a/.github/workflows/pr-auto-assign.yml b/.github/workflows/pr-auto-assign.yml new file mode 100644 index 0000000..cc46a99 --- /dev/null +++ b/.github/workflows/pr-auto-assign.yml @@ -0,0 +1,80 @@ +name: PR Auto Assign & GSSoC Label + +on: + pull_request_target: + types: [opened] + +permissions: + issues: write + pull-requests: write + +jobs: + assign-and-label: + name: Assign PR to author & add GSSoC label + runs-on: ubuntu-latest + + steps: + - name: Assign PR, add label, and post welcome comment + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const prNum = pr.number; + const author = pr.user.login; + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + + // ── 1. Ensure gssoc:approved label exists ─────────────────────── + const LABEL_NAME = 'gssoc:approved'; + const LABEL_COLOR = '7B2D8B'; // deep purple — GSSoC brand + const LABEL_DESC = 'Approved contribution for GSSoC'; + + try { + await github.rest.issues.getLabel({ ...repo, name: LABEL_NAME }); + } catch (err) { + if (err.status !== 404) throw err; + await github.rest.issues.createLabel({ + ...repo, + name: LABEL_NAME, + color: LABEL_COLOR, + description: LABEL_DESC, + }); + console.log(`✅ Created label: ${LABEL_NAME}`); + } + + // ── 2. Assign PR to its author ────────────────────────────────── + try { + await github.rest.issues.addAssignees({ + ...repo, + issue_number: prNum, + assignees: [author], + }); + console.log(`👤 Assigned PR #${prNum} to @${author}`); + } catch (err) { + console.error(`❌ Failed to assign PR #${prNum}: ${err.message}`); + } + + // ── 3. Add gssoc:approved label ───────────────────────────────── + try { + await github.rest.issues.addLabels({ + ...repo, + issue_number: prNum, + labels: [LABEL_NAME], + }); + console.log(`🏷 Applied label "${LABEL_NAME}" to PR #${prNum}`); + } catch (err) { + console.error(`❌ Failed to add label to PR #${prNum}: ${err.message}`); + } + + // ── 4. Post Welcome Comment ───────────────────────────────────── + try { + const commentBody = `Hi @${author}, thanks for contributing to **DevPath**! 🎉\n\nI have automatically:\n- 👤 Assigned this PR to you.\n- 🏷️ Applied the \`gssoc:approved\` label.\n\nOur workflows will now analyze your changes to classify:\n- 📈 **PR Difficulty:** \`level:*\`\n- 🧩 **PR Type:** \`type:*\`\n- 🌟 **PR Quality:** \`quality:*\`\n\n> [!TIP]\n> Ensure your PR description references the issue it resolves (e.g. \`Closes #123\`). This allows the bot to inherit any additional labels from that issue!\n\nHappy coding! 🚀`; + + await github.rest.issues.createComment({ + ...repo, + issue_number: prNum, + body: commentBody, + }); + console.log(`💬 Posted welcome comment on PR #${prNum}`); + } catch (err) { + console.error(`❌ Failed to post welcome comment on PR #${prNum}: ${err.message}`); + } diff --git a/.github/workflows/pr-label-inheritance.yml b/.github/workflows/pr-label-inheritance.yml new file mode 100644 index 0000000..9c803aa --- /dev/null +++ b/.github/workflows/pr-label-inheritance.yml @@ -0,0 +1,107 @@ +name: PR Label Inheritance + +on: + pull_request_target: + types: [opened, edited, reopened] + +permissions: + issues: read + pull-requests: write + +jobs: + inherit-labels: + runs-on: ubuntu-latest + steps: + - name: Inherit Labels from Issues and Add Special Labels + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const body = pr.body || ""; + const prNumber = pr.number; + + console.log(`Processing PR #${prNumber}`); + + // ── Label categories managed by dedicated workflows ───────────── + // These are intentionally excluded from inheritance to avoid + // conflicts with difficulty.yml, type-labeler.yml, quality-labeler.yml, + // and pr-auto-assign.yml (gssoc:approved). + const MANAGED_PREFIXES = [ + 'level:', // owned by difficulty.yml + 'type:', // owned by type-labeler.yml + 'quality:', // owned by quality-labeler.yml + 'gssoc:', // owned by pr-auto-assign.yml + 'mentor:', // managed manually by mentors + ]; + + function isManagedLabel(name) { + return MANAGED_PREFIXES.some(prefix => name.startsWith(prefix)); + } + + // 1. Extract linked issue numbers using regex + // Matching keywords: Fixes, Closes, Resolves, Fixed, Closed, Resolved followed by # + const regex = /(?:fixes|closes|resolves|fixed|closed|resolved)\s+#(\d+)/gi; + const matches = [...body.matchAll(regex)]; + const linkedIssues = [...new Set(matches.map(match => parseInt(match[1])))]; + + console.log(`Linked issues found: ${linkedIssues.join(', ') || 'None'}`); + + const labelsToAdd = new Set(); + + // 2. Fetch labels from linked issues (skip managed-prefix labels) + for (const issueNumber of linkedIssues) { + try { + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + + if (issue.labels && issue.labels.length > 0) { + for (const label of issue.labels) { + const labelName = typeof label === 'string' ? label : label.name; + if (isManagedLabel(labelName)) { + console.log(`Skipping managed label "${labelName}" from issue #${issueNumber} (handled by dedicated workflow)`); + continue; + } + labelsToAdd.add(labelName); + console.log(`Inheriting label "${labelName}" from issue #${issueNumber}`); + } + } + } catch (error) { + console.warn(`Could not fetch details for issue #${issueNumber}: ${error.message}`); + } + } + + // 3. Hacktoberfest Label Check + const createdAt = new Date(pr.created_at); + if (createdAt.getMonth() === 9) { // 9 is October (0-indexed) + labelsToAdd.add('hacktoberfest-accepted'); + console.log('PR created in October. Adding "hacktoberfest-accepted" label.'); + } + + // 4. Apply labels — only add ones not already on the PR (idempotency guard) + if (labelsToAdd.size > 0) { + const { data: currentIssue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + const currentLabels = new Set(currentIssue.labels.map(l => (typeof l === 'string' ? l : l.name))); + + const newLabels = [...labelsToAdd].filter(l => !currentLabels.has(l)); + + if (newLabels.length > 0) { + console.log(`Applying inherited labels to PR #${prNumber}: ${newLabels.join(', ')}`); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: newLabels + }); + } else { + console.log('All inherited labels are already present on this PR — skipping.'); + } + } else { + console.log('No labels to inherit for this PR.'); + } diff --git a/.github/workflows/pr-reviewer.yml b/.github/workflows/pr-reviewer.yml new file mode 100644 index 0000000..c910b16 --- /dev/null +++ b/.github/workflows/pr-reviewer.yml @@ -0,0 +1,37 @@ +name: Auto Reviewer & PR Handler + +on: + pull_request: + types: [opened, ready_for_review] + +permissions: + pull-requests: write + +jobs: + pr-automation: + runs-on: ubuntu-latest + steps: + - name: Assign Reviewer + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const owner = 'komalharshita'; + const prAuthor = pr.user.login; + + // Skip requesting review from the owner if the owner opened the PR themselves + if (prAuthor !== owner) { + try { + console.log(`Requesting review from ${owner} for PR #${pr.number}`); + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + reviewers: [owner] + }); + } catch (e) { + console.warn(`Failed to request review from ${owner}:`, e.message); + } + } else { + console.log("PR was opened by the repository owner, skipping self-review request."); + } diff --git a/.github/workflows/quality-labeller.yml b/.github/workflows/quality-labeller.yml new file mode 100644 index 0000000..570ee26 --- /dev/null +++ b/.github/workflows/quality-labeller.yml @@ -0,0 +1,202 @@ +name: PR Quality Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, edited] + +permissions: + issues: write + pull-requests: write + +jobs: + assign-quality-label: + name: Assign quality label + runs-on: ubuntu-latest + + steps: + - name: Detect and apply quality label + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const prNumber = pr.number; + const prTitle = (pr.title || '').toLowerCase(); + const prBody = (pr.body || '').toLowerCase(); + const repo = { + owner: context.repo.owner, + repo: context.repo.repo + }; + + // ── Label definitions ────────────────────────────────────────── + const qualityLabels = ['quality:clean', 'quality:exceptional']; + + const labelMeta = { + 'quality:clean': { + color: '0E8A16', + description: 'Well-structured, readable, and maintainable change' + }, + 'quality:exceptional': { + color: '006B75', + description: 'Outstanding quality — thorough, well-tested, exemplary contribution' + } + }; + + // ── Scoring heuristics ───────────────────────────────────────── + // A PR earns quality:clean if score >= 2 + // A PR earns quality:exceptional if score >= 5 + function scoreQuality(files, prTitle, prBody) { + let score = 0; + const reasons = []; + + const additions = files.reduce((s, f) => s + f.additions, 0); + const deletions = files.reduce((s, f) => s + f.deletions, 0); + const linesChanged = additions + deletions; + const fileNames = files.map(f => f.filename); + + // 1. Detailed description + if (prBody.length > 200) { + score++; + reasons.push('+1 Detailed description (>200 chars)'); + } + + // 2. Has a checklist + if (/- \[[ x]\]/.test(prBody)) { + score++; + reasons.push('+1 Checklist present'); + } + + // 3. Linked issue + if (/(?:closes|fixes|resolves)\s+#\d+/i.test(prBody)) { + score++; + reasons.push('+1 Linked issue reference'); + } + + // 4. Test files in diff + const hasTests = fileNames.some(f => + /\.(test|spec)\.(js|jsx|ts|tsx)$/i.test(f) || + /\/__tests__\//i.test(f) || + /\/tests?\//i.test(f) + ); + if (hasTests) { + score++; + reasons.push('+1 Test files included'); + } + + // 5. Documentation alongside code + const hasDocs = fileNames.some(f => /\.md$/i.test(f) || /\.mdx$/i.test(f)); + const hasCode = fileNames.some(f => /\.(js|jsx|ts|tsx|py|go|java|cs|rb|rs|c|cpp|h)$/i.test(f)); + if (hasDocs && hasCode) { + score++; + reasons.push('+1 Documentation alongside code changes'); + } + + // 6. Screenshots / Before-After section + if (/screenshot|before.?after|demo|preview|video/i.test(prBody)) { + score++; + reasons.push('+1 Visual demonstration (screenshots/demo)'); + } + + // 7. Testing instructions + if (/how to test|testing instructions|steps to test|to reproduce|manual test/i.test(prBody)) { + score++; + reasons.push('+1 Testing instructions provided'); + } + + // 8. Mentions quality improvements + if (/performance|accessibility|a11y|security|optimiz|refactor|clean/i.test(prBody)) { + score++; + reasons.push('+1 Mentions quality improvements (perf/a11y/security)'); + } + + // 9. Small, focused diff + if (files.length <= 10 && linesChanged <= 300) { + score++; + reasons.push('+1 Small, focused diff (<=10 files, <=300 lines)'); + } + + // 10. Meaningful clean-up (deletions >= 20% of additions) + if (additions > 0 && deletions >= additions * 0.2) { + score++; + reasons.push('+1 Contains meaningful code clean-up'); + } + + return { score, reasons }; + } + + async function getChangedFiles() { + const files = await github.paginate(github.rest.pulls.listFiles, { + ...repo, + pull_number: prNumber, + per_page: 100 + }); + return files; + } + + async function ensureQualityLabelsExist() { + for (const [name, meta] of Object.entries(labelMeta)) { + try { + await github.rest.issues.getLabel({ ...repo, name }); + } catch (err) { + if (err.status !== 404) throw err; + await github.rest.issues.createLabel({ + ...repo, + name, + color: meta.color, + description: meta.description + }); + console.log(`Created missing label: ${name}`); + } + } + } + + async function getCurrentLabels() { + const { data: issue } = await github.rest.issues.get({ + ...repo, + issue_number: prNumber + }); + return issue.labels.map(l => (typeof l === 'string' ? l : l.name)); + } + + // ── Main ─────────────────────────────────────────────────────── + const files = await getChangedFiles(); + const { score, reasons } = scoreQuality(files, prTitle, prBody); + + let targetLabel = null; + if (score >= 5) { + targetLabel = 'quality:exceptional'; + } else if (score >= 2) { + targetLabel = 'quality:clean'; + } + + console.log(`PR #${prNumber} quality score: ${score}/10`); + reasons.forEach(r => console.log(` ${r}`)); + console.log(`Target quality label: ${targetLabel || 'none (score too low)'}`); + + await ensureQualityLabelsExist(); + + const currentLabels = await getCurrentLabels(); + + const staleQualityLabels = currentLabels.filter( + l => qualityLabels.includes(l) && l !== targetLabel + ); + for (const label of staleQualityLabels) { + await github.rest.issues.removeLabel({ + ...repo, + issue_number: prNumber, + name: label + }); + console.log(`Removed stale quality label: ${label}`); + } + + if (targetLabel && !currentLabels.includes(targetLabel)) { + await github.rest.issues.addLabels({ + ...repo, + issue_number: prNumber, + labels: [targetLabel] + }); + console.log(`Applied quality label: ${targetLabel}`); + } else if (!targetLabel) { + console.log('PR quality score is below threshold — no quality label applied.'); + } else { + console.log(`Quality label already correct: ${targetLabel} — skipping.`); + } diff --git a/.github/workflows/stale-assignees.yml b/.github/workflows/stale-assignees.yml new file mode 100644 index 0000000..d18ecd2 --- /dev/null +++ b/.github/workflows/stale-assignees.yml @@ -0,0 +1,58 @@ +name: Unassign Stale Contributors + +on: + schedule: + - cron: "0 0 * * *" # Runs daily at midnight UTC + workflow_dispatch: # Allows running manually + +permissions: + issues: write + +jobs: + unassign-stale: + runs-on: ubuntu-latest + steps: + - name: Unassign stale issues + uses: actions/github-script@v7 + with: + script: | + const daysLimit = 3; + const msLimit = daysLimit * 24 * 60 * 60 * 1000; + const now = new Date(); + + console.log(`Checking for issues assigned more than ${daysLimit} days ago with no activity...`); + + // Fetch open assigned issues + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + assignee: '*' + }); + + for (const issue of issues.data) { + if (issue.pull_request) continue; // Skip PRs + + const lastUpdated = new Date(issue.updated_at); + const elapsed = now - lastUpdated; + + if (elapsed > msLimit) { + console.log(`Unassigning issue #${issue.number} due to inactivity.`); + + // Comment on the issue to inform the contributor + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `Hi @${issue.assignees.map(a => a.login).join(', @')}, this issue has been unassigned due to no activity/updates for ${daysLimit} days. This keeps the repository active and open for other GSSoC contributors! Feel free to request it again if you are ready to submit a PR soon.` + }); + + // Unassign + await github.rest.issues.removeAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: issue.assignees.map(a => a.login) + }); + } + } diff --git a/.github/workflows/type-labeller.yml b/.github/workflows/type-labeller.yml new file mode 100644 index 0000000..b180325 --- /dev/null +++ b/.github/workflows/type-labeller.yml @@ -0,0 +1,223 @@ +name: PR Type Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, edited] + +permissions: + issues: write + pull-requests: write + +jobs: + assign-type-label: + name: Assign type label(s) + runs-on: ubuntu-latest + + steps: + - name: Detect and apply type labels + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const prNumber = pr.number; + const prTitle = (pr.title || '').toLowerCase(); + const branchName = (pr.head.ref || '').toLowerCase(); + const repo = { + owner: context.repo.owner, + repo: context.repo.repo + }; + + // ── Label definitions ────────────────────────────────────────── + const typeLabels = [ + 'type:bug', + 'type:feature', + 'type:docs', + 'type:testing', + 'type:security', + 'type:performance', + 'type:design', + 'type:refactor', + 'type:devops', + 'type:accessibility' + ]; + + const labelMeta = { + 'type:bug': { color: 'd73a4a', description: 'Something is broken or incorrect' }, + 'type:feature': { color: '0075ca', description: 'New feature or enhancement' }, + 'type:docs': { color: '0052cc', description: 'Improvements or additions to documentation' }, + 'type:testing': { color: '5319e7', description: 'Tests added or improved' }, + 'type:security': { color: 'e4e669', description: 'Security improvement or fix' }, + 'type:performance': { color: 'f9d0c4', description: 'Performance improvement' }, + 'type:design': { color: 'fef2c0', description: 'UI/UX design changes' }, + 'type:refactor': { color: 'c5def5', description: 'Code refactoring without behavior change' }, + 'type:devops': { color: 'bfd4f2', description: 'CI/CD, infrastructure, or tooling changes' }, + 'type:accessibility': { color: 'bfd4f2', description: 'Accessibility improvements' } + }; + + // ── Keyword rules (title + branch) ───────────────────────────── + const keywordRules = [ + { + label: 'type:bug', + keywords: ['fix', 'bug', 'patch', 'hotfix', 'revert', 'regression', 'crash', 'error', 'issue'] + }, + { + label: 'type:feature', + keywords: ['feat', 'feature', 'add', 'new', 'implement', 'enhance', 'improvement', 'support'] + }, + { + label: 'type:docs', + keywords: ['doc', 'docs', 'documentation', 'readme', 'changelog', 'wiki', 'comment', 'guide'] + }, + { + label: 'type:testing', + keywords: ['test', 'tests', 'spec', 'e2e', 'unit', 'integration', 'coverage', 'jest', 'cypress'] + }, + { + label: 'type:security', + keywords: ['security', 'auth', 'vuln', 'vulnerability', 'cve', 'sanitize', 'escape', 'xss', 'csrf', 'permission', 'token', 'secret'] + }, + { + label: 'type:performance', + keywords: ['perf', 'performance', 'speed', 'optimize', 'optim', 'cache', 'lazy', 'memo', 'slow', 'fast', 'bundle', 'compress'] + }, + { + label: 'type:design', + keywords: ['design', 'style', 'styles', 'css', 'ui', 'ux', 'layout', 'theme', 'color', 'responsive', 'animation', 'font', 'icon'] + }, + { + label: 'type:refactor', + keywords: ['refactor', 'cleanup', 'clean', 'restructure', 'reorganize', 'rename', 'move', 'simplify', 'extract'] + }, + { + label: 'type:devops', + keywords: ['ci', 'cd', 'pipeline', 'workflow', 'action', 'deploy', 'docker', 'infra', 'helm', 'config', 'env', 'build', 'release', 'devops', 'github-actions'] + }, + { + label: 'type:accessibility', + keywords: ['a11y', 'accessibility', 'aria', 'wcag', 'screen reader', 'keyboard', 'contrast', 'alt text'] + } + ]; + + // ── File path rules ──────────────────────────────────────────── + const fileRules = [ + { + label: 'type:docs', + patterns: [/\.md$/i, /\.mdx$/i, /^docs\//i, /readme/i, /changelog/i, /^wiki\//i] + }, + { + label: 'type:testing', + patterns: [/\.test\.(js|jsx|ts|tsx)$/i, /\.spec\.(js|jsx|ts|tsx)$/i, /\/__tests__\//i, /\/tests?\//i, /cypress\//i, /jest\.config/i] + }, + { + label: 'type:devops', + patterns: [/^\.github\/workflows\//i, /^\.github\/actions\//i, /dockerfile/i, /docker-compose/i, /\.yaml$/i, /\.yml$/i, /^infra\//i, /^helm\//i] + }, + { + label: 'type:design', + patterns: [/\.css$/i, /\.scss$/i, /\.sass$/i, /\.less$/i, /tailwind\.config/i, /theme\.(js|ts)/i, /style(s)?\.(js|ts)/i] + }, + { + label: 'type:security', + patterns: [/auth\.(js|ts|jsx|tsx)/i, /middleware\/auth/i, /jwt/i, /token/i, /\.env/i] + }, + { + label: 'type:performance', + patterns: [/lazy/i, /memo/i, /cache/i, /bundle/i, /optimize/i, /webpack/i, /vite\.config/i] + } + ]; + + // ── Helpers ──────────────────────────────────────────────────── + async function ensureTypeLabelsExist() { + for (const [name, meta] of Object.entries(labelMeta)) { + try { + await github.rest.issues.getLabel({ ...repo, name }); + } catch (err) { + if (err.status !== 404) throw err; + await github.rest.issues.createLabel({ + ...repo, + name, + color: meta.color, + description: meta.description + }); + console.log(`Created missing label: ${name}`); + } + } + } + + async function getChangedFiles() { + const files = await github.paginate(github.rest.pulls.listFiles, { + ...repo, + pull_number: prNumber, + per_page: 100 + }); + return files.map(f => f.filename); + } + + async function getCurrentLabels() { + const { data: issue } = await github.rest.issues.get({ + ...repo, + issue_number: prNumber + }); + return issue.labels.map(l => (typeof l === 'string' ? l : l.name)); + } + + function detectFromText(text, rules) { + const matched = new Set(); + for (const rule of rules) { + if (rule.keywords.some(kw => text.includes(kw))) { + matched.add(rule.label); + } + } + return matched; + } + + function detectFromFiles(filenames, rules) { + const matched = new Set(); + for (const rule of rules) { + if (filenames.some(f => rule.patterns.some(p => p.test(f)))) { + matched.add(rule.label); + } + } + return matched; + } + + // ── Main ─────────────────────────────────────────────────────── + const combinedText = `${prTitle} ${branchName}`; + const changedFiles = await getChangedFiles(); + + const detectedLabels = new Set([ + ...detectFromText(combinedText, keywordRules), + ...detectFromFiles(changedFiles, fileRules) + ]); + + console.log(`PR #${prNumber} | Title: "${pr.title}" | Branch: "${pr.head.ref}"`); + console.log(`Files changed: ${changedFiles.length}`); + console.log(`Detected type labels: ${[...detectedLabels].join(', ') || 'none'}`); + + await ensureTypeLabelsExist(); + + const currentLabels = await getCurrentLabels(); + + const staleTypeLabels = currentLabels.filter( + l => typeLabels.includes(l) && !detectedLabels.has(l) + ); + for (const label of staleTypeLabels) { + await github.rest.issues.removeLabel({ + ...repo, + issue_number: prNumber, + name: label + }); + console.log(`Removed stale type label: ${label}`); + } + + const labelsToAdd = [...detectedLabels].filter(l => !currentLabels.includes(l)); + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + ...repo, + issue_number: prNumber, + labels: labelsToAdd + }); + console.log(`Added type labels: ${labelsToAdd.join(', ')}`); + } else { + console.log('No new type labels to add — all detected labels already present.'); + }