diff --git a/.github/workflows/app-intent-types-triage-notifications.yml b/.github/workflows/app-intent-types-triage-notifications.yml new file mode 100644 index 0000000..b07cd8e --- /dev/null +++ b/.github/workflows/app-intent-types-triage-notifications.yml @@ -0,0 +1,287 @@ +name: App intent type triage notifications + +on: + discussion: + types: [created] + pull_request_target: + types: [opened, reopened, ready_for_review] + schedule: + # Weekdays at 16:00 UTC. Adjust if the team wants a different triage time. + - cron: "0 16 * * 1-5" + workflow_dispatch: + +permissions: + contents: read + discussions: read + issues: read + pull-requests: read + +env: + OWNER: Shopify + REPO: app-intent-types + RFC_CATEGORY_SLUG: rfc + SLA_BUSINESS_DAYS: "5" + # GitHub logins whose comments/reviews satisfy the 5-business-day maintainer response SLA. + # Keep this narrower than OWNER/MEMBER/COLLABORATOR. On a public Shopify repo, + # MEMBER can be any Shopify org member, not necessarily an app intent type maintainer. + MAINTAINER_LOGINS: adambarrus,vividviolet,Fionoble + +jobs: + notify: + name: Notify Slack + runs-on: ubuntu-latest + env: + SLACK_WEBHOOK_URL: ${{ secrets.APP_INTENT_TYPES_SLACK_WEBHOOK_URL }} + steps: + - name: Notify new requests and stale SLA misses + uses: actions/github-script@v7 + with: + script: | + const webhook = process.env.SLACK_WEBHOOK_URL; + if (!webhook) { + core.setFailed('Missing APP_INTENT_TYPES_SLACK_WEBHOOK_URL repository secret. Create an incoming webhook for the target Slack channel and store it in that secret.'); + return; + } + + const owner = process.env.OWNER; + const repo = process.env.REPO; + const rfcCategorySlug = process.env.RFC_CATEGORY_SLUG; + const slaBusinessDays = Number(process.env.SLA_BUSINESS_DAYS || '5'); + const maintainerLogins = new Set( + (process.env.MAINTAINER_LOGINS || '') + .split(',') + .map((value) => value.trim()) + .map((value) => value.toLowerCase()) + .filter(Boolean), + ); + + function escapeSlack(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\|/g, '¦') + // Keep user-supplied PR/discussion text from creating noisy Slack mentions. + .replace(/@/g, '@\u200b'); + } + + function slackLink(url, label) { + return `<${url}|${escapeSlack(label)}>`; + } + + async function postSlack(text) { + const response = await fetch(webhook, { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + text, + unfurl_links: false, + unfurl_media: false, + }), + }); + + if (!response.ok) { + throw new Error(`Slack webhook failed with ${response.status}: ${await response.text()}`); + } + } + + function businessDaysBetween(startIso, end = new Date()) { + const start = new Date(startIso); + const cursor = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate())); + const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate())); + let days = 0; + + while (cursor < endDay) { + cursor.setUTCDate(cursor.getUTCDate() + 1); + const day = cursor.getUTCDay(); + if (day !== 0 && day !== 6) days += 1; + } + + return days; + } + + function isMaintainerResponse(node) { + const login = node?.author?.login?.toLowerCase(); + return Boolean(login && maintainerLogins.has(login)); + } + + function hasMaintainerDiscussionResponse(discussion) { + if (discussion.answerChosenAt) return true; + return discussion.comments.nodes.some(isMaintainerResponse); + } + + function hasMaintainerPullRequestResponse(pullRequest) { + return ( + pullRequest.comments.nodes.some(isMaintainerResponse) || + pullRequest.reviews.nodes.some((review) => review.state !== 'PENDING' && isMaintainerResponse(review)) + ); + } + + function formatItem(item) { + const age = `${item.businessDaysOld} business day${item.businessDaysOld === 1 ? '' : 's'} old`; + return `• ${item.kind}: ${slackLink(item.url, `#${item.number} ${item.title}`)} by \`${escapeSlack(item.author)}\` (${age})`; + } + + async function notifyNewDiscussion() { + const discussion = context.payload.discussion; + if (!discussion) return; + + const categorySlug = discussion.category?.slug; + if (categorySlug && categorySlug !== rfcCategorySlug) { + core.info(`Discussion is in category ${categorySlug}, not ${rfcCategorySlug}; skipping.`); + return; + } + + await postSlack([ + ':speech_balloon: New app intent type RFC discussion', + `${slackLink(discussion.html_url, `#${discussion.number} ${discussion.title}`)} by \`${escapeSlack(discussion.user?.login || 'unknown')}\``, + `Please triage within ${slaBusinessDays} business days.`, + ].join('\n')); + } + + async function notifyNewPullRequest() { + const pullRequest = context.payload.pull_request; + if (!pullRequest) return; + + await postSlack([ + ':github: New app intent type proposal PR', + `${slackLink(pullRequest.html_url, `#${pullRequest.number} ${pullRequest.title}`)} by \`${escapeSlack(pullRequest.user?.login || 'unknown')}\`${pullRequest.draft ? ' (draft)' : ''}`, + `Please triage within ${slaBusinessDays} business days.`, + ].join('\n')); + } + + async function getRfcCategoryId() { + const data = await github.graphql(` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + discussionCategories(first: 50) { + nodes { id name slug } + } + } + } + `, {owner, repo}); + + const categories = data.repository.discussionCategories.nodes; + const category = categories.find((node) => node.slug === rfcCategorySlug); + if (!category) { + core.warning(`Could not find discussion category slug ${rfcCategorySlug}. Available categories: ${categories.map((node) => node.slug).join(', ')}`); + return null; + } + + return category.id; + } + + async function collectStaleItems() { + const staleItems = []; + const rfcCategoryId = await getRfcCategoryId(); + + if (rfcCategoryId) { + const discussionData = await github.graphql(` + query($owner: String!, $repo: String!, $categoryId: ID!) { + repository(owner: $owner, name: $repo) { + discussions(first: 50, categoryId: $categoryId, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + number + title + url + createdAt + answerChosenAt + author { login } + comments(first: 100) { + nodes { + authorAssociation + author { login } + createdAt + } + } + } + } + } + } + `, {owner, repo, categoryId: rfcCategoryId}); + + for (const discussion of discussionData.repository.discussions.nodes) { + const businessDaysOld = businessDaysBetween(discussion.createdAt); + if (businessDaysOld >= slaBusinessDays && !hasMaintainerDiscussionResponse(discussion)) { + staleItems.push({ + kind: 'RFC discussion', + number: discussion.number, + title: discussion.title, + url: discussion.url, + author: discussion.author?.login || 'unknown', + businessDaysOld, + }); + } + } + } + + const pullRequestData = await github.graphql(` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 50, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + number + title + url + createdAt + author { login } + comments(first: 100) { + nodes { + authorAssociation + author { login } + createdAt + } + } + reviews(first: 100) { + nodes { + authorAssociation + author { login } + createdAt + state + } + } + } + } + } + } + `, {owner, repo}); + + for (const pullRequest of pullRequestData.repository.pullRequests.nodes) { + const businessDaysOld = businessDaysBetween(pullRequest.createdAt); + if (businessDaysOld >= slaBusinessDays && !hasMaintainerPullRequestResponse(pullRequest)) { + staleItems.push({ + kind: 'proposal PR', + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author?.login || 'unknown', + businessDaysOld, + }); + } + } + + return staleItems.sort((left, right) => right.businessDaysOld - left.businessDaysOld); + } + + if (context.eventName === 'discussion') { + await notifyNewDiscussion(); + return; + } + + if (context.eventName === 'pull_request_target') { + await notifyNewPullRequest(); + return; + } + + const staleItems = await collectStaleItems(); + if (staleItems.length === 0) { + core.info('No stale app intent type RFC discussions or proposal PRs.'); + return; + } + + await postSlack([ + `:alarm_clock: App intent type response SLA check found ${staleItems.length} item${staleItems.length === 1 ? '' : 's'} with no maintainer response after ${slaBusinessDays} business days.`, + ...staleItems.map(formatItem), + '', + `Maintainer response means a GitHub comment, PR review, or accepted discussion answer by one of: ${Array.from(maintainerLogins).join(', ')}.`, + ].join('\n'));