Auto-assign on open workflow (projectdiscovery/nuclei-templates)
The Auto-assign on open workflow from projectdiscovery/nuclei-templates, explained and optimized by Latchkey.
CI health: A - excellent
Point runs-on at Latchkey and get job timeouts, self-healing for flaky steps, and up to 58% lower cost, applied automatically.
What it does
This is the Auto-assign on open workflow from the projectdiscovery/nuclei-templates repository, a real project running GitHub Actions. It is shown here with attribution under its MIT license.
Below, Latchkey shows a faster, safer version produced by its optimization engine.
The workflow
name: Auto-assign on open
on:
issues:
types: [opened, reopened]
# Use pull_request_target so the job has permissions on PRs from forks.
pull_request_target:
types: [opened, ready_for_review, reopened]
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: auto-assign-on-open
cancel-in-progress: false
jobs:
assign:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
with:
script: |
const ORG = context.repo.owner;
const REPO = context.repo.repo;
// ======= CONFIG: put your user pools here =======
// Usernames must be GitHub logins and collaborators on this repo.
const ISSUE_ASSIGNEES = [
"pussycat0x","ritikchaddha","DhiyaneshGeek","akokonunes","theamanrawat"
];
const REVIEW_POOL = [
"pussycat0x","ritikchaddha","DhiyaneshGeek","akokonunes","theamanrawat"
];
const LOOKBACK_DAYS = 7; // only used as a tie-break fairness metric
// ================================================
function toLowerAll(xs){ return xs.map(x => x.toLowerCase()); }
const issuePool = new Set(toLowerAll(ISSUE_ASSIGNEES));
const reviewPool = new Set(toLowerAll(REVIEW_POOL));
const sinceISO = new Date(Date.now() - LOOKBACK_DAYS*24*60*60*1000).toISOString();
// Simple "least recent then fewest in lookback" picker
function initMap(arr){ const m=new Map(); for(const a of arr) m.set(a,0); return m; }
const issueCounts = initMap(issuePool);
const prAssigneeCounts = initMap(reviewPool);
const prReviewerCounts = initMap(reviewPool);
const lastIssueAssign = new Map();
const lastPrAssignee = new Map();
const lastPrReviewer = new Map();
function pick(countsMap, lastMap, exclude=new Set()){
const cands = [...countsMap.keys()].filter(x => !exclude.has(x));
if (!cands.length) return null;
cands.sort((a,b)=>{
const ca=countsMap.get(a)||0, cb=countsMap.get(b)||0;
if (ca!==cb) return ca-cb;
const ta=lastMap.get(a)?.getTime()||0, tb=lastMap.get(b)?.getTime()||0;
if (ta!==tb) return ta-tb;
return a.localeCompare(b);
});
return cands[0];
}
async function buildIssueStats(){
for await (const page of github.paginate.iterator(
github.rest.issues.listForRepo,
{ owner: ORG, repo: REPO, state: 'all', since: sinceISO, per_page: 100 }
)){
for (const it of page.data){
if (it.pull_request) continue;
const ts = new Date(it.created_at);
if (ts < new Date(sinceISO)) continue;
for (const a of (it.assignees||[])){
const l=a.login.toLowerCase();
if (issuePool.has(l)){
issueCounts.set(l,(issueCounts.get(l)||0)+1);
const lastTs = lastIssueAssign.get(l);
if (!lastTs || ts > lastTs) lastIssueAssign.set(l, ts);
}
}
}
}
}
async function buildPrStats(){
for await (const page of github.paginate.iterator(
github.rest.pulls.list,
{ owner: ORG, repo: REPO, state: 'all', per_page: 100 }
)){
for (const pr of page.data){
const ts = new Date(pr.created_at);
if (ts < new Date(sinceISO)) continue;
// Count all assignees (plural)
for (const a of (pr.assignees||[])){
const l = a.login.toLowerCase();
if (reviewPool.has(l)){
prAssigneeCounts.set(l,(prAssigneeCounts.get(l)||0)+1);
const lastTs = lastPrAssignee.get(l);
if (!lastTs || ts > lastTs) lastPrAssignee.set(l, ts);
}
}
// Count all reviewers separately
for (const r of (pr.requested_reviewers||[])){
const l=r.login.toLowerCase();
if (reviewPool.has(l)){
prReviewerCounts.set(l,(prReviewerCounts.get(l)||0)+1);
const lastTs = lastPrReviewer.get(l);
if (!lastTs || ts > lastTs) lastPrReviewer.set(l, ts);
}
}
}
}
}
// Build stats once per run (cheap enough for small repos)
await Promise.all([buildIssueStats(), buildPrStats()]);
// Determine context
if (context.eventName === 'issues') {
const issue = context.payload.issue;
if (issue.pull_request) return; // guard
if ((issue.assignees||[]).length>0) return;
const pickee = pick(issueCounts, lastIssueAssign);
if (!pickee) return;
await github.rest.issues.addAssignees({
owner: ORG, repo: REPO, issue_number: issue.number, assignees: [pickee]
});
} else if (context.eventName === 'pull_request_target') {
const pr = context.payload.pull_request;
const prNum = pr.number;
const author = (pr.user?.login||"").toLowerCase();
const assignee = pr.assignee?.login?.toLowerCase();
// Ensure one assignee (use assignee counts only)
let finalAssignee = assignee;
if (!finalAssignee) {
const a = pick(prAssigneeCounts, lastPrAssignee, new Set([author]));
if (a) {
await github.rest.issues.addAssignees({
owner: ORG, repo: REPO, issue_number: prNum, assignees: [a]
});
finalAssignee = a;
}
}
// One reviewer, not the author, not the assignee (use reviewer counts only)
const already = new Set((pr.requested_reviewers||[]).map(x=>x.login.toLowerCase()));
if (already.size === 0 && !pr.draft) {
const exclude = new Set([author, finalAssignee].filter(Boolean));
const reviewer = pick(prReviewerCounts, lastPrReviewer, exclude);
if (reviewer) {
await github.rest.pulls.requestReviewers({
owner: ORG, repo: REPO, pull_number: prNum, reviewers: [reviewer]
});
}
}
}
The same workflow, on Latchkey
Removes redundant runs and caps runaway jobs. Added and changed lines are highlighted.
name: Auto-assign on open on: issues: types: [opened, reopened] # Use pull_request_target so the job has permissions on PRs from forks. pull_request_target: types: [opened, ready_for_review, reopened] permissions: contents: read issues: write pull-requests: write concurrency: group: auto-assign-on-open cancel-in-progress: false jobs: assign: timeout-minutes: 30 runs-on: latchkey-small steps: - uses: actions/github-script@v8 with: script: | const ORG = context.repo.owner; const REPO = context.repo.repo; // ======= CONFIG: put your user pools here ======= // Usernames must be GitHub logins and collaborators on this repo. const ISSUE_ASSIGNEES = [ "pussycat0x","ritikchaddha","DhiyaneshGeek","akokonunes","theamanrawat" ]; const REVIEW_POOL = [ "pussycat0x","ritikchaddha","DhiyaneshGeek","akokonunes","theamanrawat" ]; const LOOKBACK_DAYS = 7; // only used as a tie-break fairness metric // ================================================ function toLowerAll(xs){ return xs.map(x => x.toLowerCase()); } const issuePool = new Set(toLowerAll(ISSUE_ASSIGNEES)); const reviewPool = new Set(toLowerAll(REVIEW_POOL)); const sinceISO = new Date(Date.now() - LOOKBACK_DAYS*24*60*60*1000).toISOString(); // Simple "least recent then fewest in lookback" picker function initMap(arr){ const m=new Map(); for(const a of arr) m.set(a,0); return m; } const issueCounts = initMap(issuePool); const prAssigneeCounts = initMap(reviewPool); const prReviewerCounts = initMap(reviewPool); const lastIssueAssign = new Map(); const lastPrAssignee = new Map(); const lastPrReviewer = new Map(); function pick(countsMap, lastMap, exclude=new Set()){ const cands = [...countsMap.keys()].filter(x => !exclude.has(x)); if (!cands.length) return null; cands.sort((a,b)=>{ const ca=countsMap.get(a)||0, cb=countsMap.get(b)||0; if (ca!==cb) return ca-cb; const ta=lastMap.get(a)?.getTime()||0, tb=lastMap.get(b)?.getTime()||0; if (ta!==tb) return ta-tb; return a.localeCompare(b); }); return cands[0]; } async function buildIssueStats(){ for await (const page of github.paginate.iterator( github.rest.issues.listForRepo, { owner: ORG, repo: REPO, state: 'all', since: sinceISO, per_page: 100 } )){ for (const it of page.data){ if (it.pull_request) continue; const ts = new Date(it.created_at); if (ts < new Date(sinceISO)) continue; for (const a of (it.assignees||[])){ const l=a.login.toLowerCase(); if (issuePool.has(l)){ issueCounts.set(l,(issueCounts.get(l)||0)+1); const lastTs = lastIssueAssign.get(l); if (!lastTs || ts > lastTs) lastIssueAssign.set(l, ts); } } } } } async function buildPrStats(){ for await (const page of github.paginate.iterator( github.rest.pulls.list, { owner: ORG, repo: REPO, state: 'all', per_page: 100 } )){ for (const pr of page.data){ const ts = new Date(pr.created_at); if (ts < new Date(sinceISO)) continue; // Count all assignees (plural) for (const a of (pr.assignees||[])){ const l = a.login.toLowerCase(); if (reviewPool.has(l)){ prAssigneeCounts.set(l,(prAssigneeCounts.get(l)||0)+1); const lastTs = lastPrAssignee.get(l); if (!lastTs || ts > lastTs) lastPrAssignee.set(l, ts); } } // Count all reviewers separately for (const r of (pr.requested_reviewers||[])){ const l=r.login.toLowerCase(); if (reviewPool.has(l)){ prReviewerCounts.set(l,(prReviewerCounts.get(l)||0)+1); const lastTs = lastPrReviewer.get(l); if (!lastTs || ts > lastTs) lastPrReviewer.set(l, ts); } } } } } // Build stats once per run (cheap enough for small repos) await Promise.all([buildIssueStats(), buildPrStats()]); // Determine context if (context.eventName === 'issues') { const issue = context.payload.issue; if (issue.pull_request) return; // guard if ((issue.assignees||[]).length>0) return; const pickee = pick(issueCounts, lastIssueAssign); if (!pickee) return; await github.rest.issues.addAssignees({ owner: ORG, repo: REPO, issue_number: issue.number, assignees: [pickee] }); } else if (context.eventName === 'pull_request_target') { const pr = context.payload.pull_request; const prNum = pr.number; const author = (pr.user?.login||"").toLowerCase(); const assignee = pr.assignee?.login?.toLowerCase(); // Ensure one assignee (use assignee counts only) let finalAssignee = assignee; if (!finalAssignee) { const a = pick(prAssigneeCounts, lastPrAssignee, new Set([author])); if (a) { await github.rest.issues.addAssignees({ owner: ORG, repo: REPO, issue_number: prNum, assignees: [a] }); finalAssignee = a; } } // One reviewer, not the author, not the assignee (use reviewer counts only) const already = new Set((pr.requested_reviewers||[]).map(x=>x.login.toLowerCase())); if (already.size === 0 && !pr.draft) { const exclude = new Set([author, finalAssignee].filter(Boolean)); const reviewer = pick(prReviewerCounts, lastPrReviewer, exclude); if (reviewer) { await github.rest.pulls.requestReviewers({ owner: ORG, repo: REPO, pull_number: prNum, reviewers: [reviewer] }); } } }
What changed
- Run on Latchkey managed runners with one line (
runs-on), which apply the fixes below automatically and self-heal transient failures. This example useslatchkey-small; pick the runner size that fits the job. - Add a job timeout so a hung step cannot burn hours of runner time.
This workflow runs 1 job per trigger. On Latchkey the same minutes cost up to 58% less than GitHub-hosted, with zero queue time.