Skip to content
Merged
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
287 changes: 287 additions & 0 deletions .github/workflows/app-intent-types-triage-notifications.yml
Original file line number Diff line number Diff line change
@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.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'));
Loading