From f166fefbcc552b0e681f041353367c8ff46d3d8a Mon Sep 17 00:00:00 2001 From: Tanay Mishra Date: Tue, 22 Sep 2026 16:16:35 +0530 Subject: [PATCH] Add files via upload --- find-duplicate-issues.yml | 194 ++++++++++++++++++++++++++++++++++ update-contributors-table.yml | 150 ++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 find-duplicate-issues.yml create mode 100644 update-contributors-table.yml diff --git a/find-duplicate-issues.yml b/find-duplicate-issues.yml new file mode 100644 index 0000000..f932e96 --- /dev/null +++ b/find-duplicate-issues.yml @@ -0,0 +1,194 @@ +name: Find & Close Duplicate Issues + +on: + workflow_dispatch: + inputs: + issue_number: + description: "Issue # to check for duplicates (blank = scan all open issues)" + required: false + type: string + dry_run: + description: "Dry run (comment nothing, just print what would happen)" + required: false + type: boolean + default: false + +permissions: + issues: write + contents: read + +jobs: + find-duplicates: + runs-on: ubuntu-latest + steps: + - name: Fetch open issues + id: fetch + uses: actions/github-script@v7 + with: + script: | + const perPage = 100; + let issues = []; + let page = 1; + while (true) { + const { data } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: perPage, + page, + }); + issues.push(...data.filter(i => !i.pull_request)); + if (data.length < perPage) break; + page++; + } + + const slim = issues.map(i => ({ + number: i.number, + title: i.title, + body: (i.body || "").slice(0, 2000), + comments: i.comments, + reactions: i.reactions ? i.reactions.total_count : 0, + created_at: i.created_at, + url: i.html_url, + })); + + core.setOutput("issues_json", JSON.stringify(slim)); + + - name: Ask Groq for duplicate groups + id: ai + env: + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + ISSUES_JSON: ${{ steps.fetch.outputs.issues_json }} + TARGET_ISSUE: ${{ inputs.issue_number }} + run: | + node <<'NODEEOF' + const fs = require("fs"); + + async function main() { + const issuesJson = process.env.ISSUES_JSON; + const targetIssue = process.env.TARGET_ISSUE || ""; + + let systemPrompt = "You detect duplicate GitHub issues for a small Python library called LeanPass (a lightweight NumPy autodiff library). You will be given a JSON array of open issues (number, title, body, comments, reactions, created_at, url). Group issues that describe the SAME underlying bug, request, or question, not just a similar topic. Two issues are duplicates only if resolving one would also resolve or answer the other. Be conservative: when in doubt, do NOT group them. Respond with ONLY minified JSON, no prose, no markdown fences, matching this shape: {\"groups\":[{\"issue_numbers\":[12,15],\"reason\":\"short reason these are duplicates\"}]}. If there are no duplicates, respond with {\"groups\":[]}."; + + if (targetIssue) { + systemPrompt += ` Only consider groups that include issue #${targetIssue}. Ignore all other potential duplicate pairs that do not involve it.`; + } + + const response = await fetch("https://api.groq.com/openai/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${process.env.GROQ_API_KEY}`, + }, + body: JSON.stringify({ + model: "llama-3.3-70b-versatile", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: issuesJson }, + ], + temperature: 0, + max_tokens: 1000, + }), + }); + + if (!response.ok) { + const text = await response.text(); + console.error(text); + process.exit(1); + } + + const data = await response.json(); + const content = data.choices[0].message.content; + fs.appendFileSync(process.env.GITHUB_OUTPUT, `response< { + const scoreA = (a.reactions?.total_count || 0) + a.comments; + const scoreB = (b.reactions?.total_count || 0) + b.comments; + if (scoreB !== scoreA) return scoreB - scoreA; + return new Date(a.created_at) - new Date(b.created_at); + }); + + const keeper = details[0]; + const duplicates = details.slice(1); + + for (const dup of duplicates) { + const message = [ + `Thanks for filing this! This looks like a duplicate of #${keeper.number} (${keeper.title}).`, + "", + `Reason: ${group.reason || "same underlying issue"}`, + "", + `Closing this one in favor of #${keeper.number} — please follow/comment there. If you think this was closed in error, feel free to reopen with more detail on how it differs.`, + ].join("\n"); + + core.info(`${dryRun ? "[dry-run] " : ""}Duplicate: #${dup.number} -> keeping #${keeper.number}`); + core.info(message); + + if (!dryRun) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: dup.number, + body: message, + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: dup.number, + state: "closed", + state_reason: "not_planned", + }); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: dup.number, + labels: ["duplicate"], + }); + } catch (e) { + core.info(`Skipping label (may not exist): ${e.message}`); + } + } + } + } diff --git a/update-contributors-table.yml b/update-contributors-table.yml new file mode 100644 index 0000000..9173421 --- /dev/null +++ b/update-contributors-table.yml @@ -0,0 +1,150 @@ +name: Update Contributors Table + +on: + push: + branches: [main] + workflow_dispatch: {} + +permissions: + contents: write + pull-requests: read + +concurrency: + group: update-contributors-table + cancel-in-progress: true + +jobs: + update-table: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build contributors table + id: build + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + + const perPage = 100; + let page = 1; + let allPRs = []; + while (true) { + const { data } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: "closed", + base: "main", + sort: "created", + direction: "asc", + per_page: perPage, + page, + }); + allPRs.push(...data); + if (data.length < perPage) break; + page++; + } + + const mergedPRs = allPRs + .filter(pr => pr.merged_at) + .sort((a, b) => new Date(a.merged_at) - new Date(b.merged_at)); + + const firstPRByAuthor = new Map(); + for (const pr of mergedPRs) { + const login = pr.user?.login; + if (!login) continue; + if (pr.user.type === "Bot") continue; + if (!firstPRByAuthor.has(login)) { + firstPRByAuthor.set(login, pr); + } + } + + firstPRByAuthor.delete(context.repo.owner); + + const rows = [...firstPRByAuthor.entries()].map(([login, pr]) => { + const name = pr.user.login; + const followUrl = `https://github.com/${login}`; + const prUrl = pr.html_url; + return `| [${name}](${followUrl}) | [Follow](${followUrl}) | [#${pr.number}](${prUrl}) |`; + }); + + const tableHeader = [ + "| Contributor | Follow | First PR |", + "| ----------- | ------ | -------- |", + ]; + + const table = rows.length > 0 + ? [...tableHeader, ...rows].join("\n") + : "_No external contributors yet — open a PR to be the first!_"; + + fs.writeFileSync("/tmp/contributors_table.md", table); + core.setOutput("count", rows.length); + + - name: Insert table into README + run: | + set -e + README="README.md" + TABLE_FILE="/tmp/contributors_table.md" + START_MARK="" + END_MARK="" + + if grep -qF "$START_MARK" "$README"; then + python3 - "$README" "$START_MARK" "$END_MARK" "$TABLE_FILE" <<'PYEOF' + import sys + readme_path, start_mark, end_mark, table_path = sys.argv[1:5] + with open(readme_path, "r", encoding="utf-8") as f: + content = f.read() + with open(table_path, "r", encoding="utf-8") as f: + table = f.read() + start_idx = content.index(start_mark) + len(start_mark) + end_idx = content.index(end_mark) + new_content = content[:start_idx] + "\n\n" + table + "\n\n" + content[end_idx:] + with open(readme_path, "w", encoding="utf-8") as f: + f.write(new_content) + PYEOF + else + python3 - "$README" "$START_MARK" "$END_MARK" "$TABLE_FILE" <<'PYEOF' + import re, sys + readme_path, start_mark, end_mark, table_path = sys.argv[1:5] + with open(readme_path, "r", encoding="utf-8") as f: + content = f.read() + with open(table_path, "r", encoding="utf-8") as f: + table = f.read() + + block = ( + "\n## Contributors\n\n" + f"{start_mark}\n\n{table}\n\n{end_mark}\n" + ) + + anchor = "## Community and contribution" + idx = content.find(anchor) + if idx == -1: + new_content = content.rstrip() + "\n" + block + else: + search_from = idx + len(anchor) + next_heading = re.search(r"\n## ", content[search_from:]) + if next_heading: + insert_at = search_from + next_heading.start() + 1 + new_content = content[:insert_at] + block + "\n" + content[insert_at:] + else: + new_content = content.rstrip() + "\n" + block + + with open(readme_path, "w", encoding="utf-8") as f: + f.write(new_content) + PYEOF + fi + + - name: Commit changes if any + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git diff --quiet -- README.md; then + echo "No changes to README.md" + else + git add README.md + git commit -m "chore: update contributors table [skip ci]" + git push + fi