Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
389 changes: 389 additions & 0 deletions .editorconfig

Large diffs are not rendered by default.

180 changes: 180 additions & 0 deletions .github/discord-bot/blocking.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { config } from './config.mjs'
import {
getIssue,
nativeBlockedBy,
nativeBlocking,
searchTextDependents,
} from './github.mjs'

// "Blocked by", "blocked-by:", "depends on", "waiting on", "blocker:"
const KEYWORD =
/(?:blocked[\s_-]*by|depends?[\s_-]*(?:up)?on|waiting[\s_-]*on|blockers?)\s*:?/gi

// "#12", "owner/repo#12", or a full issue URL, anchored at the current position.
const REF =
/^(?:(?:([\w.-]+)\/([\w.-]+))?#(\d+)|https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/(\d+))/i

// Whitespace, commas, "and", and list/checkbox markers between consecutive refs.
const SEPARATOR = /^(?:[\s,;&]+|and\b|[-*+]|\[[ xX]?\])+/i

/**
* Pull blocker references out of an issue body.
* Only refs following a blocking keyword count, so a passing "see also #99"
* elsewhere in the description is not mistaken for a dependency.
*/
export function parseBlockerRefs(body, defaults) {
const text = String(body ?? '')
const out = new Map()
KEYWORD.lastIndex = 0

let keyword
while ((keyword = KEYWORD.exec(text)) !== null) {
let rest = text.slice(keyword.index + keyword[0].length)

for (;;) {
const gap = rest.match(SEPARATOR)
if (gap) rest = rest.slice(gap[0].length)

const ref = rest.match(REF)
if (!ref) break

const owner = ref[1] ?? ref[4] ?? defaults.owner
const repo = ref[2] ?? ref[5] ?? defaults.repo
const number = Number(ref[3] ?? ref[6])

const self =
number === defaults.number &&
owner.toLowerCase() === defaults.owner.toLowerCase() &&
repo.toLowerCase() === defaults.repo.toLowerCase()
if (!self)
out.set(`${owner}/${repo}#${number}`.toLowerCase(), {
owner,
repo,
number,
})

rest = rest.slice(ref[0].length)
if (!SEPARATOR.test(rest)) break
}
}
return [...out.values()]
}

export function hasBlockedLabel(issue) {
const labels = (issue?.labels ?? []).map((l) =>
String(l.name ?? l).toLowerCase(),
)
return labels.some((name) => config.blockedLabels.includes(name))
}

/**
* Everything standing between an issue and someone starting work on it:
* native GitHub dependencies, "blocked by #N" text, and blocking labels.
*/
export async function getBlockState(owner, repo, issue) {
const number = issue.number
const blockers = new Map()

for (const dep of await nativeBlockedBy(owner, repo, number)) {
const key =
`${dep.repository?.full_name ?? `${owner}/${repo}`}#${dep.number}`.toLowerCase()
blockers.set(key, {
number: dep.number,
title: dep.title,
state: dep.state,
html_url: dep.html_url,
})
}

for (const ref of parseBlockerRefs(issue.body, { owner, repo, number })) {
const key = `${ref.owner}/${ref.repo}#${ref.number}`.toLowerCase()
if (blockers.has(key)) continue
const target = await getIssue(ref.owner, ref.repo, ref.number)
if (!target) continue // deleted or unreadable — do not block on a ghost
blockers.set(key, {
number: target.number,
title: target.title,
state: target.state,
html_url: target.html_url,
})
}

const all = [...blockers.values()]
const open = all.filter((b) => b.state === 'open')
const labelBlocked = hasBlockedLabel(issue)

return {
blockers: all,
openBlockers: open,
labelBlocked,
blocked: open.length > 0 || labelBlocked,
}
}

/** Issues that were waiting on this one — natively linked and text-linked. */
export async function findDependents(owner, repo, number) {
const found = new Map()

for (const dep of await nativeBlocking(owner, repo, number)) {
const full = dep.repository?.full_name ?? `${owner}/${repo}`
const [o, r] = full.split('/')
found.set(`${full}#${dep.number}`.toLowerCase(), {
owner: o,
repo: r,
number: dep.number,
})
}

for (const item of await searchTextDependents(owner, repo, number)) {
found.set(`${owner}/${repo}#${item.number}`.toLowerCase(), {
owner,
repo,
number: item.number,
})
}

return [...found.values()]
}

/**
* How many still-open issues are waiting on this one.
*
* Uses only the native dependency links — the text search behind findDependents costs
* scarce search-API quota, which is too expensive to spend per issue in a digest.
*/
export async function countBlocking(owner, repo, number) {
try {
const deps = await nativeBlocking(owner, repo, number)
return deps.filter((d) => d.state === 'open').length
} catch (err) {
// Unknown is not zero. Callers omit the claim rather than assert "blocks nothing".
console.warn(
`[blocking] could not count dependents of #${number}: ${err.message}`,
)
return null
}
}

/** "blocking 3 other issues", or "" for zero and for unknown. */
export function blockingPhrase(count) {
if (!count) return ''
return `blocking ${count} other issue${count === 1 ? '' : 's'}`
}

/** Embed field value for a blocking count, honest about a failed lookup. */
export function blockingFieldValue(count) {
if (count === null) return 'could not check'
return count > 0
? `⛔ ${count} other open issue${count === 1 ? '' : 's'}`
: 'nothing'
}

export function formatBlockerList(blockers) {
if (blockers.length === 0) return 'none'
return blockers
.map(
(b) =>
`[#${b.number}](${b.html_url}) ${String(b.title ?? '').slice(0, 60)}`,
)
.join('\n')
}
128 changes: 128 additions & 0 deletions .github/discord-bot/config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const here = path.dirname(fileURLToPath(import.meta.url))

const list = (v) =>
(v ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)

export const config = {
discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL || '',
discordReviewWebhookUrl:
process.env.DISCORD_REVIEW_WEBHOOK_URL ||
process.env.DISCORD_WEBHOOK_URL ||
'',
githubToken: process.env.GITHUB_TOKEN || '',
blockedLabels: list(process.env.BLOCKED_LABELS || 'blocked').map((s) =>
s.toLowerCase(),
),
// Set ALLOW_EVERYONE=false in a busy server: team-wide messages then name each
// mapped teammate individually instead of firing a real @everyone.
allowEveryone:
(process.env.ALLOW_EVERYONE || 'true').toLowerCase() !== 'false',
}

/**
* GitHub login -> Discord user id.
*
* Read from the DISCORD_USER_MAP secret when set, so teammates' Discord ids stay out
* of a public repo. Falls back to a committed usermap.json for teams that would
* rather edit a file than a secret.
*/
function loadUserMap() {
let raw = null

if (process.env.DISCORD_USER_MAP) {
try {
raw = JSON.parse(process.env.DISCORD_USER_MAP)
} catch (err) {
console.error(
'::error::DISCORD_USER_MAP secret is not valid JSON:',
err.message,
)
}
}

if (!raw) {
const file = path.join(here, 'usermap.json')
try {
raw = JSON.parse(fs.readFileSync(file, 'utf8'))
} catch {
raw = {}
}
}

const map = {}
for (const [login, id] of Object.entries(raw)) {
if (login.startsWith('_')) continue // comment keys
map[login.toLowerCase()] = String(id)
}
return map
}

let userMap = null
const getUserMap = () => (userMap ??= loadUserMap())

/**
* A person, named the way Discord should show them: `@TheirName (their-github-login)`.
*
* The Discord mention alone renders as a display name that often looks nothing like
* the GitHub account it belongs to, which makes "who is that?" a real question on a
* team message. Carrying the login in brackets answers it without a second lookup.
*
* Only a mention that also appears in allowed_mentions actually notifies anyone, so
* this is safe to use for third parties we merely want to name — they render as a
* highlighted mention but stay silent unless the caller pings them on purpose.
*
* Someone missing from the user map has no Discord name to show, so their GitHub
* handle stands alone.
*/
export function mention(login) {
if (!login) return 'someone'
const id = getUserMap()[String(login).toLowerCase()]
return id ? `<@${id}> (${login})` : `**@${login}**`
}

/** Discord ids for allowed_mentions — the allowlist of who this message may ping. */
export function mentionIds(logins) {
const map = getUserMap()
return [
...new Set(
logins.map((l) => map[String(l ?? '').toLowerCase()]).filter(Boolean),
),
]
}

/**
* How to address the whole team in one line.
*
* `@everyone` when the server allows it, otherwise every mapped teammate by name —
* the same intent, delivered the only other way we can deliver it. Returns the
* logins that must be allowlisted so the fallback actually notifies anyone.
*/
export function everyoneMention() {
if (config.allowEveryone) return { text: '@everyone', logins: [] }
const logins = Object.keys(getUserMap())
return { text: logins.map(mention).join(' ') || 'everyone', logins }
}

export function assertConfig() {
if (!config.discordWebhookUrl) {
console.error(
'::error::DISCORD_WEBHOOK_URL is not set. Add it as a repository secret.',
)
process.exit(1)
}
const mapped = Object.keys(getUserMap()).length
console.log(`[config] ${mapped} GitHub login(s) mapped to Discord ids`)
if (mapped === 0) {
console.warn(
'::warning::No user map found — people will be named in messages but not pinged. ' +
'Set the DISCORD_USER_MAP secret or commit .github/discord-bot/usermap.json.',
)
}
}
70 changes: 70 additions & 0 deletions .github/discord-bot/discord.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { config, mentionIds } from './config.mjs'

export const COLORS = {
unblocked: 0x2ea043, // green — go
ready: 0x1f6feb, // blue — assigned and actionable
review: 0xd29922, // amber — needs a human
merged: 0x8957e5, // purple — GitHub's merge colour
blocked: 0x8b949e, // grey — informational
}

/**
* Post one message to a Discord channel webhook.
*
* `pingLogins` are GitHub logins; only their mapped Discord ids end up in
* allowed_mentions, so an @everyone typed into an issue title stays inert.
*
* `everyone: true` is the one way to make a real @everyone fire, and only the bot's
* own message text can trigger it — never text copied out of GitHub. Reserved for
* things the whole team has to see: sprint progress, and work nobody has picked up.
*/
export async function sendDiscord({
content,
embed,
pingLogins = [],
review = false,
everyone = false,
}) {
const url = review ? config.discordReviewWebhookUrl : config.discordWebhookUrl
if (!url) return

const body = {
content,
embeds: embed ? [embed] : undefined,
allowed_mentions: {
parse: everyone && config.allowEveryone ? ['everyone'] : [],
users: mentionIds(pingLogins),
},
}

for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})

if (res.status === 429) {
const retry = Number(res.headers.get('retry-after') || 1)
console.warn(`[discord] rate limited, waiting ${retry}s`)
await new Promise((r) => setTimeout(r, retry * 1000 + 250))
continue
}

if (!res.ok) {
console.error(
`::error::Discord returned ${res.status}: ${await res.text()}`,
)
return
}

console.log(`[discord] sent: ${content.slice(0, 90)}`)
return
}
console.error('::error::Discord kept rate limiting; message dropped.')
}

export function clamp(text, max = 300) {
const s = String(text ?? '').trim()
return s.length <= max ? s : `${s.slice(0, max - 1)}…`
}
Loading
Loading