feat: Add new newsletter design #21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Issue Command Automation | |
| on: | |
| issue_comment: | |
| types: [created] | |
| permissions: | |
| issues: write | |
| pull-requests: read | |
| jobs: | |
| handle-issue-commands: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Parse and handle issue commands | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| if (context.payload.issue.pull_request) return; | |
| const comment = context.payload.comment.body.trim(); | |
| const actor = context.payload.sender.login; | |
| const issue_number = context.payload.issue.number; | |
| const repo_owner = context.repo.owner; | |
| const repo_name = context.repo.repo; | |
| const issue_labels = context.payload.issue.labels.map(l => l.name); | |
| // Config variables | |
| const FRONTEND_TEAM = (process.env.FRONTEND_TEAM || '').split(',').map(u => u.trim()).filter(Boolean); | |
| const BACKEND_TEAM = (process.env.BACKEND_TEAM || '').split(',').map(u => u.trim()).filter(Boolean); | |
| const ASSIGN_ALLOWLIST = (process.env.ASSIGN_ALLOWLIST || '').split(',').map(u => u.trim()).filter(Boolean); | |
| const MAINTAINER_ALLOWLIST = (process.env.MAINTAINER_ALLOWLIST || '').split(',').map(u => u.trim()).filter(Boolean); | |
| const WORKING_LABEL = (process.env.WORKING_LABEL || 'in progress').trim(); | |
| // Command parsing | |
| const assignMatch = comment.match(/^\/assign(?:\s+@?(\w[\w-]+))?$/); | |
| const unassignMatch = comment.match(/^\/unassign(?:\s+@?(\w[\w-]+))?$/); | |
| const workingMatch = comment.match(/^\/working$/); | |
| if (!assignMatch && !unassignMatch && !workingMatch) return; | |
| const assignees = context.payload.issue.assignees.map(a => a.login); | |
| const toSet = list => new Set(list.map(v => v.toLowerCase())); | |
| const assigneeSet = toSet(assignees); | |
| const maintainerSet = toSet(MAINTAINER_ALLOWLIST); | |
| const actorIsMaintainer = maintainerSet.has(actor.toLowerCase()); | |
| async function upsertWorkingMarker(timestampIso) { | |
| const marker = `[bot-working:${timestampIso}]`; | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: repo_owner, | |
| repo: repo_name, | |
| issue_number, | |
| per_page: 100 | |
| }); | |
| const existing = comments.find(c => | |
| c.user?.login === 'github-actions[bot]' && | |
| /\[bot-working:[^\]]+\]/.test(c.body || '') | |
| ); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: repo_owner, | |
| repo: repo_name, | |
| comment_id: existing.id, | |
| body: marker | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: repo_owner, | |
| repo: repo_name, | |
| issue_number, | |
| body: marker | |
| }); | |
| } | |
| } | |
| async function addReaction(content) { | |
| await github.rest.reactions.createForIssueComment({ | |
| comment_id: context.payload.comment.id, | |
| owner: repo_owner, | |
| repo: repo_name, | |
| content | |
| }); | |
| } | |
| async function commentIssue(body) { | |
| await github.rest.issues.createComment({ | |
| issue_number, | |
| owner: repo_owner, | |
| repo: repo_name, | |
| body | |
| }); | |
| } | |
| if (workingMatch) { | |
| const actorIsAssigned = assigneeSet.has(actor.toLowerCase()); | |
| if (!actorIsAssigned && !actorIsMaintainer) { | |
| await addReaction('confused'); | |
| await commentIssue('Only a current assignee (or maintainer allowlist member) can use `/working`.'); | |
| return; | |
| } | |
| if (!issue_labels.some(l => l.toLowerCase() === WORKING_LABEL.toLowerCase())) { | |
| await github.rest.issues.addLabels({ | |
| issue_number, | |
| owner: repo_owner, | |
| repo: repo_name, | |
| labels: [WORKING_LABEL] | |
| }); | |
| } | |
| const nowIso = new Date().toISOString(); | |
| await upsertWorkingMarker(nowIso); | |
| await addReaction('rocket'); | |
| await commentIssue(`Marked as actively worked on by @${actor}.`); | |
| return; | |
| } | |
| // Assignment rules | |
| const allowedLabels = ['ready', 'help wanted', 'good first issue', 'available']; | |
| const restrictedLabels = ['security', 'private']; | |
| const frontendLabel = 'frontend'; | |
| const backendLabel = 'backend'; | |
| // Helper: check org membership | |
| async function isOrgMember(username) { | |
| try { | |
| const res = await github.rest.orgs.checkMembershipForUser({ | |
| org: repo_owner, | |
| username | |
| }); | |
| return res.status === 204; | |
| } catch (e) { | |
| return false; | |
| } | |
| } | |
| // Helper: check collaborator | |
| async function isCollaborator(username) { | |
| try { | |
| const res = await github.rest.repos.checkCollaborator({ | |
| owner: repo_owner, | |
| repo: repo_name, | |
| username | |
| }); | |
| return res.status === 204; | |
| } catch (e) { | |
| return false; | |
| } | |
| } | |
| // Determine target user | |
| let targetUser = actor; | |
| if (assignMatch && assignMatch[1]) targetUser = assignMatch[1]; | |
| if (unassignMatch && unassignMatch[1]) targetUser = unassignMatch[1]; | |
| // Assignment restrictions | |
| if (assignMatch) { | |
| // Blocked labels | |
| const blockedLabels = ['blocked', 'do-not-assign', 'needs-triage']; | |
| if (issue_labels.some(l => blockedLabels.includes(l))) { | |
| await commentIssue('Cannot assign: blocked label present.'); | |
| return; | |
| } | |
| // Only allow self-assignment if allowed label present | |
| if (targetUser === actor && !issue_labels.some(l => allowedLabels.includes(l))) { | |
| await commentIssue('Cannot self-assign until label help wanted, ready, available, or good first issue is applied.'); | |
| return; | |
| } | |
| // Restricted labels | |
| if (issue_labels.some(l => restrictedLabels.includes(l))) { | |
| if (!ASSIGN_ALLOWLIST.includes(targetUser)) { | |
| const isMember = await isOrgMember(targetUser); | |
| const isCollab = await isCollaborator(targetUser); | |
| if (!isMember && !isCollab) { | |
| await commentIssue('Assignment restricted: only org members, collaborators, or allowlisted users may be assigned.'); | |
| return; | |
| } | |
| } | |
| } | |
| // Frontend label | |
| if (issue_labels.includes(frontendLabel) && !FRONTEND_TEAM.includes(targetUser)) { | |
| await commentIssue('Assignment restricted: only frontend team members may be assigned.'); | |
| return; | |
| } | |
| // Backend label | |
| if (issue_labels.includes(backendLabel) && !BACKEND_TEAM.includes(targetUser)) { | |
| await commentIssue('Assignment restricted: only backend team members may be assigned.'); | |
| return; | |
| } | |
| // Already assigned? | |
| if (assignees.includes(targetUser)) { | |
| await commentIssue(`Already assigned to @${targetUser}.`); | |
| return; | |
| } | |
| // Assign | |
| try { | |
| await github.rest.issues.addAssignees({ | |
| issue_number, | |
| owner: repo_owner, | |
| repo: repo_name, | |
| assignees: [targetUser] | |
| }); | |
| await addReaction('rocket'); | |
| await commentIssue(`Assigned to @${targetUser}.`); | |
| } catch (e) { | |
| await commentIssue(`Assignment failed: ${e.message}`); | |
| } | |
| return; | |
| } | |
| // Unassign | |
| if (unassignMatch) { | |
| if (targetUser !== actor && !actorIsMaintainer) { | |
| await addReaction('confused'); | |
| await commentIssue('You may only unassign yourself unless you are in `MAINTAINER_ALLOWLIST`.'); | |
| return; | |
| } | |
| if (!assignees.includes(targetUser)) { | |
| await commentIssue(`@${targetUser} is not assigned.`); | |
| return; | |
| } | |
| try { | |
| await github.rest.issues.removeAssignees({ | |
| issue_number, | |
| owner: repo_owner, | |
| repo: repo_name, | |
| assignees: [targetUser] | |
| }); | |
| const refreshed = await github.rest.issues.get({ | |
| owner: repo_owner, | |
| repo: repo_name, | |
| issue_number | |
| }); | |
| const remaining = refreshed.data.assignees?.length || 0; | |
| if (remaining === 0 && issue_labels.some(l => l.toLowerCase() === WORKING_LABEL.toLowerCase())) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: repo_owner, | |
| repo: repo_name, | |
| issue_number, | |
| name: WORKING_LABEL | |
| }); | |
| } catch (err) { | |
| if (err.status !== 404) throw err; | |
| } | |
| } | |
| await addReaction('eyes'); | |
| if (remaining === 0) { | |
| await commentIssue(`Unassigned @${targetUser}. This issue is now available for others to assign.`); | |
| } else { | |
| await commentIssue(`Unassigned @${targetUser}.`); | |
| } | |
| } catch (e) { | |
| await commentIssue(`Unassignment failed: ${e.message}`); | |
| } | |
| } | |
| env: | |
| FRONTEND_TEAM: ${{ vars.FRONTEND_TEAM }} | |
| BACKEND_TEAM: ${{ vars.BACKEND_TEAM }} | |
| ASSIGN_ALLOWLIST: ${{ vars.ASSIGN_ALLOWLIST }} | |
| MAINTAINER_ALLOWLIST: ${{ vars.MAINTAINER_ALLOWLIST }} | |
| WORKING_LABEL: ${{ vars.WORKING_LABEL }} |