├── .gitignore ├── buf.gen.yaml ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20240125185626_follow_ups │ │ └── migration.sql │ └── 20240109175925_restructure │ │ └── migration.sql └── schema.prisma ├── api ├── index.ts └── auth │ ├── index.ts │ └── callback.ts ├── config ├── draw-dino.yaml ├── spam.yaml ├── sinerider.yaml ├── site.yaml ├── blot.yaml ├── dns.yaml ├── hack-af.yaml ├── onboard.yaml ├── slash-z.yaml ├── welcomers.yaml ├── ysws.yaml ├── pizza.yaml ├── scrapbook.yaml ├── clubs.yaml ├── engineering.yaml └── sprig.yaml ├── .github ├── ISSUE_TEMPLATE │ ├── add-or-remove-a-slacker-maintainer.md │ └── bug_report.md └── workflows │ └── validate.yml ├── lib ├── metrics.ts ├── db.ts ├── types.ts ├── elastic.ts ├── views.ts ├── utils.ts ├── blocks.ts └── octokit.ts ├── actions ├── index.ts ├── unsnooze.ts ├── gimme.ts ├── resolve.ts ├── irrelevant.ts ├── notes.ts ├── delay.ts └── assign.ts ├── manifest.yml ├── CONTRIBUTING.md ├── proto └── eliza.proto ├── cron ├── review.ts ├── unsnooze.ts ├── unassign.ts ├── followUp.ts └── report.ts ├── package.json ├── text-analysis.ts ├── gen ├── eliza_connect.ts └── eliza_pb.ts ├── maintainers.yaml ├── README.md ├── events ├── message.ts └── helpers.ts ├── index.ts └── routes.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules/ 3 | migrate.js -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | plugins: 3 | - plugin: es 4 | opt: target=ts 5 | out: gen 6 | - plugin: connect-es 7 | opt: target=ts 8 | out: gen -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | export const indexHandler = async (_: Request, res: Response) => { 4 | res.send("Hello World!"); 5 | }; 6 | -------------------------------------------------------------------------------- /config/draw-dino.yaml: -------------------------------------------------------------------------------- 1 | name: draw-dino 2 | description: Hack Club's workshop on submitting PRs on GitHub 3 | maintainers: [graham, josias, max] 4 | repos: 5 | - uri: https://github.com/hackclub/draw-dino 6 | -------------------------------------------------------------------------------- /config/spam.yaml: -------------------------------------------------------------------------------- 1 | name: Spam 2 | description: Bot spam testing 3 | private: true 4 | maintainers: [faisal] 5 | channels: 6 | - name: bot-spam 7 | grouping: 8 | minutes: 10 9 | id: C0P5NE354 10 | repos: [] 11 | -------------------------------------------------------------------------------- /config/sinerider.yaml: -------------------------------------------------------------------------------- 1 | name: Sinerider 2 | description: A game about math and graphing! 3 | maintainers: [chris, elliot] 4 | channels: 5 | - name: gamedev 6 | id: C6LHL48G2 7 | repos: 8 | - uri: https://github.com/hackclub/sinerider 9 | -------------------------------------------------------------------------------- /config/site.yaml: -------------------------------------------------------------------------------- 1 | name: Hack Club Site 2 | description: The Hack Club website 3 | maintainers: [faisal, graham, josias] 4 | channels: 5 | repos: 6 | - uri: https://github.com/hackclub/site 7 | sections: 8 | - name: sprig 9 | pattern: sprig 10 | -------------------------------------------------------------------------------- /config/blot.yaml: -------------------------------------------------------------------------------- 1 | name: Blot 2 | description: Teens make art and get a Blot 3 | maintainers: [leo] 4 | channels: 5 | - name: blot 6 | id: C04GCH8A91D 7 | repos: 8 | - uri: https://github.com/hackclub/blot 9 | resources: 10 | - name: github 11 | uri: https://github.com/hackclub/blot 12 | -------------------------------------------------------------------------------- /config/dns.yaml: -------------------------------------------------------------------------------- 1 | name: DNS 2 | description: DNS stuff for Hack Club 3 | maintainers: [faisal, graham] 4 | channels: 5 | - name: dns 6 | id: CS8TQ86GN 7 | repos: 8 | - uri: https://github.com/hackclub/dns 9 | notify: [] 10 | resources: 11 | - name: github 12 | uri: https://github.com/hackclub/dns 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/add-or-remove-a-slacker-maintainer.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Add or remove a slacker maintainer 3 | about: Create a ticket to request a slacker maintainer being added or removed from 4 | a project 5 | title: '' 6 | labels: help wanted 7 | assignees: faisalsayed10 8 | 9 | --- 10 | 11 | Project: 12 | Maintainers to add: 13 | Maintainers to remove: 14 | -------------------------------------------------------------------------------- /api/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | export const authHandler = async (req: Request, res: Response) => { 4 | const id = req.query.id; 5 | 6 | if (!id) return res.json({ error: "No user id provided for the slack user" }); 7 | 8 | const url = `https://github.com/login/oauth/authorize?client_id=${process.env.GITHUB_CLIENT_ID}&redirect_uri=${process.env.DEPLOY_URL}/auth/callback&state=${id}`; 9 | res.redirect(url); 10 | }; 11 | -------------------------------------------------------------------------------- /config/hack-af.yaml: -------------------------------------------------------------------------------- 1 | name: hack.af 2 | description: Hack Club URL infrastructure 3 | maintainers: [faisal, graham, josias, max] 4 | repos: 5 | - uri: https://github.com/hackclub/hack.af 6 | resources: 7 | - name: github 8 | uri: https://github.com/hackclub/hack.af 9 | - name: dashboard 10 | uri: https://telemetry.hackclub.com/d/a12b6f93-28fd-4a37-aa86-d5b6edfc20c8/hack-af?orgId=1 11 | - name: heroku 12 | uri: https://dashboard.heroku.com/apps/hack-af 13 | -------------------------------------------------------------------------------- /config/onboard.yaml: -------------------------------------------------------------------------------- 1 | name: Onboard 2 | description: Teens make a PCB for $100 and get it shipped for free 3 | maintainers: [prophetorpheus, max] 4 | channels: 5 | - name: onboard 6 | id: C056AMWSFKJ 7 | - name: onboard-bts 8 | id: C05AB4G6PPV 9 | - name: onboard-help 10 | id: C0593MG26TT 11 | - name: onboard-grant-requests 12 | id: C063HR4LB4H 13 | repos: 14 | - uri: https://github.com/hackclub/onboard 15 | sections: 16 | - name: grants 17 | pattern: A new Onboard grant has been requested 18 | -------------------------------------------------------------------------------- /lib/metrics.ts: -------------------------------------------------------------------------------- 1 | import { StatsD } from 'node-statsd'; 2 | import dotenv from 'dotenv'; 3 | 4 | dotenv.config(); 5 | 6 | const environment = process.env.NODE_ENV || 'development'; 7 | const graphite = process.env.GRAPHITE_HOST; 8 | 9 | if (graphite === null) { 10 | throw new Error('Graphite host not configured!'); 11 | } 12 | 13 | const options = { 14 | host: graphite, 15 | port: 8125, 16 | prefix: `${environment}.slacker.`, 17 | }; 18 | 19 | const metrics = new StatsD(options); 20 | 21 | export default metrics; -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: json-yaml-validate 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pull-requests: write 12 | 13 | jobs: 14 | json-yaml-validate: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: json-yaml-validate 20 | id: json-yaml-validate 21 | uses: GrantBirki/json-yaml-validate@v2.3.1 22 | with: 23 | comment: "true" -------------------------------------------------------------------------------- /config/slash-z.yaml: -------------------------------------------------------------------------------- 1 | name: Slash Z 2 | description: Hack Club zoooom infrastructure 3 | maintainers: [graham, josias, max] 4 | channels: 5 | - name: development-of-slash-z 6 | id: C0179FFFRRV 7 | repos: 8 | - uri: https://github.com/hackclub/slash-z 9 | resources: 10 | - name: github 11 | uri: https://github.com/hackclub/slash-z 12 | - name: dashboard 13 | uri: https://telemetry.hackclub.com/d/f3869cb3-1e23-4ff2-84b1-74f9d10cf535/slash-z?orgId=1 14 | - name: heroku 15 | uri: https://dashboard.heroku.com/pipelines/618d59d1-52db-4498-a207-7a400dcd3b40 16 | -------------------------------------------------------------------------------- /config/welcomers.yaml: -------------------------------------------------------------------------------- 1 | name: Welcomers 2 | description: A group of people that give warm welcomes to new Hack Clubbers! 3 | maintainers: [chris, vivian, ryan, arav, cskartikey, alex, nikos, srijit] 4 | channels: 5 | - name: welcome-committee 6 | id: GLFAEL1SL 7 | - name: welcome 8 | id: C75M7C0SY 9 | - name: bloom-dev-spam 10 | id: C06SU9YMC6R 11 | resources: 12 | - name: welcomers-leaderboard 13 | uri: https://welcomers-leaderboard.hackclub.dev/ 14 | - name: welcome-scripts 15 | uri: https://hackclub.slack.com/docs/T0266FRGM/F054X091EG2 16 | repos: [] 17 | -------------------------------------------------------------------------------- /config/ysws.yaml: -------------------------------------------------------------------------------- 1 | name: You Ship We Ship 2 | description: Centralized You Ship We Ship for Hack Clubbers 3 | maintainers: [faisal, graham] 4 | channels: 5 | - name: ysws-submissions 6 | id: C06U25JH0E5 7 | notify: [graham] 8 | - name: verification-requests 9 | id: C06TP6YPWKC 10 | notify: [] 11 | repos: 12 | - uri: https://github.com/hackclub/ysws 13 | sections: 14 | - name: verify 15 | pattern: A new verification request has been submitted 16 | - name: projects 17 | pattern: A new YSWS project has been submitted 18 | - name: feedback 19 | pattern: Feedback 20 | -------------------------------------------------------------------------------- /config/pizza.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pizza 3 | description: Pizza for everyone! 4 | maintainers: [thomas, arpan, sahiti] 5 | channels: 6 | - name: pizza-party 7 | id: C05RZ6K7RS5 8 | notify: [jasper, sarah] 9 | - name: pizza-grant-approval 10 | id: C05RZATA3QR 11 | notify: [] 12 | - name: pizza-grant-renewal 13 | id: C06HR3TE6TA 14 | notify: [] 15 | - name: pizza-queries 16 | id: C063JHFQV1S 17 | notify: [] 18 | - name: pizza-dev 19 | id: C06CR1YQQAV 20 | notify: [] 21 | repos: 22 | - uri: https://github.com/hackclub/pizza-fund 23 | notify: [] 24 | sections: 25 | - name: india 26 | pattern: india 27 | notify: [arpan] 28 | -------------------------------------------------------------------------------- /actions/index.ts: -------------------------------------------------------------------------------- 1 | import { Middleware, SlackAction, SlackActionMiddlewareArgs } from "@slack/bolt"; 2 | import { StringIndexed } from "@slack/bolt/dist/types/helpers"; 3 | import { assign } from "./assign"; 4 | import { followUp, snooze } from "./delay"; 5 | import { gimmeAgain } from "./gimme"; 6 | import { markIrrelevant } from "./irrelevant"; 7 | import { notes } from "./notes"; 8 | import { resolve } from "./resolve"; 9 | import { unsnooze } from "./unsnooze"; 10 | 11 | export interface ActionHandler 12 | extends Middleware, StringIndexed> {} 13 | 14 | export { assign, followUp, gimmeAgain, markIrrelevant, notes, resolve, snooze, unsnooze }; 15 | -------------------------------------------------------------------------------- /config/scrapbook.yaml: -------------------------------------------------------------------------------- 1 | name: Scrapbook 2 | description: Hack Club Scrapbook infrastructure 3 | maintainers: [graham, josias] 4 | repos: 5 | - uri: https://github.com/hackclub/scrappy 6 | - uri: https://github.com/hackclub/scrapbook 7 | resources: 8 | - name: github-scrapbook 9 | uri: https://github.com/hackclub/scrapbook 10 | - name: github-scrappy 11 | uri: https://github.com/hackclub/scrappy 12 | - name: dashboard-scrapbook 13 | uri: https://telemetry.hackclub.com/d/cf0fd2bf-efec-49a0-a879-a1a8c7e5a1b6/scrapbook?orgId=1&refresh=5s 14 | - name: dashboard-scrappy 15 | uri: https://telemetry.hackclub.com/d/d2a0f592-2c77-4725-8e81-841963964907/scrappy?orgId=1&refresh=5s 16 | -------------------------------------------------------------------------------- /config/clubs.yaml: -------------------------------------------------------------------------------- 1 | name: Clubs Team 2 | description: Place for helping the leaders of Hack Clubs 3 | maintainers: [thomas, arpan, sahiti] 4 | channels: 5 | - name: leaders 6 | id: C02PA5G01ND 7 | notify: [sarah] 8 | - name: application-conspiracy 9 | id: C02F9GD407J 10 | notify: [] 11 | repos: 12 | - uri: https://github.com/hackclub/jams 13 | notify: [jasper] 14 | - uri: https://github.com/hackclub/toolbox 15 | notify: [arpan] 16 | - uri: https://github.com/hackclub/workshops 17 | notify: [jasper] 18 | - uri: https://github.com/hackclub/application-viewer 19 | notify: [jasper, arpan] 20 | - uri: https://github.com/hackclub/apply 21 | notify: [jasper] 22 | - uri: https://github.com/hackclub/turnover 23 | notify: [jasper] 24 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import metrics from "./metrics"; 3 | 4 | const prisma = new PrismaClient({ 5 | log: ["error", "warn", "info"], 6 | errorFormat: "pretty", 7 | }).$extends({ 8 | // extend prisma client 9 | // to send query metrics such as latency & failures 10 | query: { 11 | async $allOperations({ operation, model, args, query }) { 12 | const metricKey = `${operation}_${model}`; 13 | try { 14 | const start = performance.now(); 15 | const queryResult = await query(args); 16 | const time = performance.now() - start; 17 | 18 | metrics.timing(metricKey, time); 19 | 20 | return queryResult; 21 | } catch (err) { 22 | metrics.increment(`errors.${metricKey}`, 1); 23 | } 24 | return; 25 | }, 26 | }, 27 | }); 28 | export default prisma; 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: grymmy 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /config/engineering.yaml: -------------------------------------------------------------------------------- 1 | name: HQ Engineering 2 | description: HQ Engineering stuff for Hack Club 3 | maintainers: [faisal, graham, josias, marios, shawn, snwy] 4 | clawback: true 5 | channels: 6 | - name: infra-alerts 7 | grouping: 8 | minutes: 10 9 | id: C05G25Y9M6V 10 | - name: hq-engineering 11 | grouping: 12 | minutes: 10 13 | id: C05SVRTCDGV 14 | repos: 15 | - uri: https://github.com/hackclub/slacker 16 | - uri: https://github.com/hackclub/infra 17 | - uri: https://github.com/hackclub/global 18 | resources: 19 | - name: hq-eng-slack-channel 20 | uri: https://hackclub.slack.com/archives/C05SVRTCDGV 21 | - name: grafana 22 | uri: https://telemetry.hackclub.com 23 | - name: heroku 24 | uri: https://dashboard.heroku.com 25 | - name: digitalocean 26 | uri: https://www.digitalocean.com 27 | - name: dashboard-slacker 28 | uri: https://telemetry.hackclub.com/d/fe0c8789-033d-4add-9cd6-6e38a4d2bde6/slacker?orgId=1 29 | - name: alerts-slack-channel 30 | uri: https://hackclub.slack.com/archives/C05G25Y9M6V 31 | -------------------------------------------------------------------------------- /actions/unsnooze.ts: -------------------------------------------------------------------------------- 1 | import { ActionHandler } from "."; 2 | import prisma from "../lib/db"; 3 | import { indexDocument } from "../lib/elastic"; 4 | import metrics from "../lib/metrics"; 5 | import { logActivity } from "../lib/utils"; 6 | 7 | export const unsnooze: ActionHandler = async ({ ack, body, client, logger }) => { 8 | await ack(); 9 | 10 | try { 11 | const { user, channel, actions } = body as any; 12 | const actionId = actions[0].value; 13 | 14 | await prisma.actionItem.update({ 15 | where: { id: actionId }, 16 | data: { snoozedUntil: null, snoozeCount: { decrement: 1 }, snoozedById: null }, 17 | }); 18 | 19 | await client.chat.postEphemeral({ 20 | channel: channel?.id as string, 21 | user: user.id, 22 | text: `:white_check_mark: Action item (id=${actionId}) unsnoozed by <@${user.id}>`, 23 | }); 24 | 25 | await indexDocument(actionId); 26 | await logActivity(client, user.id, actionId, "unsnoozed"); 27 | metrics.increment("slack.unsnooze", 1); 28 | } catch (err) { 29 | metrics.increment("errors.slack.unsnooze", 1); 30 | logger.error(err); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | display_information: 2 | name: slacker-dev-123 3 | features: 4 | bot_user: 5 | display_name: slacker-dev-123 6 | always_online: true 7 | slash_commands: 8 | - command: /slacker-dev-123 9 | url: https:///slack/events 10 | description: Get action items 11 | usage_hint: "[help]" 12 | should_escape: false 13 | oauth_config: 14 | redirect_urls: 15 | - https:// 16 | scopes: 17 | user: 18 | - users:read 19 | - users:read.email 20 | - channels:history 21 | bot: 22 | - channels:history 23 | - channels:join 24 | - chat:write 25 | - chat:write.public 26 | - commands 27 | - groups:history 28 | - reactions:read 29 | - users.profile:read 30 | - users:read 31 | - users:read.email 32 | - im:write 33 | - im:history 34 | settings: 35 | event_subscriptions: 36 | request_url: https:///slack/events 37 | bot_events: 38 | - message.channels 39 | - message.groups 40 | - reaction_added 41 | - reaction_removed 42 | interactivity: 43 | is_enabled: true 44 | request_url: https:///slack/events 45 | org_deploy_enabled: false 46 | socket_mode_enabled: false 47 | token_rotation_enabled: false 48 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How do I contribute to slacker? 2 | 3 | 1. Create a slack app at https://api.slack.com/apps 4 | 2. Upload the manifest file `manifest.yml` to your app 5 | 3. Replace with a proxy tunnel url pointing to your local machine 6 | 4. Install the app to your workspace 7 | 5. Create a new Github app at https://github.com/settings/applications/new 8 | 6. Set the callback url to /auth/callback 9 | 7. Generate client secrets and private keys (encode your private key with base64 - because it is multiline) 10 | 8. Permissions: Contents, Issues, Pull requests, Metadata, Webhooks - Read-only | Email adresses - Read-only 11 | 9. Set these .env variables in your local environment: 12 | - SLACK_SIGNING_SECRET 13 | - SLACK_CLIENT_ID 14 | - SLACK_CLIENT_SECRET 15 | - SLACK_APP_TOKEN 16 | - SECRET_COOKIE_PASSWORD (RANDOM_STRING) 17 | - SLACK_BOT_TOKEN 18 | - ACTIVITY_LOG_CHANNEL_ID (#bot_spam recommended) 19 | - DATABASE_URL 20 | - GITHUB_APP_ID 21 | - GITHUB_CLIENT_ID 22 | - GITHUB_CLIENT_SECRET 23 | - GITHUB_PRIVATE_KEY (should be base64 encoded) 24 | - DEPLOY_URL (your ngrok url) 25 | 26 | 10. Setup a local postgres database and get a DATABASE_URL (e.g. postgres://user:password@localhost:5432/database) 27 | - You can use vercel-pg to run a postgres database 28 | 11. Run `yarn` and `yarn dev` to start the app -------------------------------------------------------------------------------- /proto/eliza.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package connectrpc.eliza.v1; 4 | 5 | service ElizaService { 6 | rpc SyncGithubItems(Empty) returns (Response) {} 7 | rpc AssignActionItem(AssignRequest) returns (Response) {} 8 | rpc ResolveActionItem(ActionItemRequest) returns (Response) {} 9 | rpc IrrelevantActionItem(ActionItemRequest) returns (Response) {} 10 | rpc UpdateNotes(NoteRequest) returns (Response) {} 11 | rpc SnoozeActionItem(DelayRequest) returns (Response) {} 12 | rpc FollowUpActionItem(DelayRequest) returns (Response) {} 13 | rpc GetSlackActionItem(SlackActionItemRequest) returns (SlackActionItemResponse) {} 14 | } 15 | 16 | message SyncRequest { 17 | string project = 1; 18 | } 19 | 20 | message Empty { 21 | } 22 | 23 | message Response { 24 | string response = 1; 25 | } 26 | 27 | message AssignRequest { 28 | string actionId = 1; 29 | string userId = 2; 30 | } 31 | 32 | message ActionItemRequest { 33 | string actionId = 1; 34 | optional string reason = 2; 35 | } 36 | 37 | message SlackActionItemRequest { 38 | string slackId = 1; 39 | } 40 | 41 | message SlackActionItemResponse { 42 | string actionId = 1; 43 | } 44 | 45 | message NoteRequest { 46 | string actionId = 1; 47 | string note = 2; 48 | } 49 | 50 | message DelayRequest { 51 | string actionId = 1; 52 | string userId = 2; 53 | string datetime = 3; 54 | string reason = 4; 55 | } 56 | -------------------------------------------------------------------------------- /actions/gimme.ts: -------------------------------------------------------------------------------- 1 | import { Block, KnownBlock } from "@slack/bolt"; 2 | import { ActionHandler } from "."; 3 | import { handleSlackerCommand } from "../lib/commands"; 4 | 5 | export const gimmeAgain: ActionHandler = async ({ ack, body, client, logger, ...args }) => { 6 | await ack(); 7 | 8 | try { 9 | const { user, channel, actions, message } = body as any; 10 | const command = actions[0].value as string; 11 | 12 | await handleSlackerCommand({ 13 | ack: async () => {}, 14 | // @ts-expect-error 15 | command: { channel_id: channel?.id, user_id: user.id, text: command }, 16 | client, 17 | logger, 18 | ...args, 19 | }); 20 | 21 | if (!channel?.id) return; 22 | 23 | const { messages } = await client.conversations.history({ 24 | channel: channel.id, 25 | latest: message.ts, 26 | limit: 1, 27 | inclusive: true, 28 | }); 29 | 30 | const blocks = messages?.[0].blocks || []; 31 | const idx = blocks.findIndex( 32 | (block: any) => block.elements && block.elements[0].action_id === "gimme_again" 33 | ); 34 | const newBlocks = blocks.filter((_, i) => i !== idx && i !== idx + 1) as (Block | KnownBlock)[]; 35 | 36 | await client.chat.update({ 37 | ts: message.ts, 38 | channel: channel.id, 39 | text: `Message updated: ${message.id}`, 40 | blocks: newBlocks, 41 | }); 42 | } catch (err) { 43 | logger.error(err); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /config/sprig.yaml: -------------------------------------------------------------------------------- 1 | name: Sprig 2 | description: Teens make games and get a Sprig 3 | maintainers: [lucas, graham, josias, shawn, shubham, faisal, marios, savina] 4 | channels: 5 | - name: sprig 6 | id: C02UN35M7LG 7 | grouping: 8 | minutes: 10 9 | - name: sprig-stuck-requests 10 | id: C06L5SNPPL6 11 | - name: sprig-platform 12 | id: C04S1A8NT44 13 | grouping: 14 | minutes: 10 15 | - name: sprig-device-requests 16 | id: C063DFZ532M 17 | repos: 18 | - uri: https://github.com/hackclub/sprig 19 | resources: 20 | - name: github-sprig-editor 21 | uri: https://github.com/hackclub/sprig 22 | - name: github-sprig-engine 23 | uri: https://github.com/hackclub/sprig-engine 24 | - name: github-firmware 25 | uri: https://github.com/hackclub/spade 26 | - name: dashboard 27 | uri: https://telemetry.hackclub.com/d/b7ac7960-a18f-4c83-a4e5-767d50ad62c7/sprig?orgId=1 28 | - name: vercel 29 | uri: https://vercel.com/hackclub/sprig 30 | - name: website 31 | uri: https://sprig.hackclub.com 32 | - name: editor 33 | uri: https://sprig.hackclub.com/editor 34 | sections: 35 | - name: games 36 | pattern: What is your game about 37 | - name: help 38 | pattern: help 39 | - name: devices 40 | pattern: A new Sprig device has been requested 41 | - name: devices-india 42 | pattern: A new Sprig device *in India* has been requested 43 | - name: stuck 44 | pattern: got stuck in Sprig 45 | -------------------------------------------------------------------------------- /cron/review.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from "octokit"; 2 | import { slack } from ".."; 3 | import { MAINTAINERS, getProjectDetails } from "../lib/utils"; 4 | 5 | // Runs every Friday at 12:00 PM 6 | export const reviewCron = async () => { 7 | console.log("⏳⏳ Running review requests report cron job ⏳⏳"); 8 | try { 9 | for await (const maintainer of MAINTAINERS) { 10 | let text = `:wave: Hey ${maintainer.id}!`; 11 | 12 | const { repositories } = await getProjectDetails("all", maintainer.slack, maintainer.github); 13 | 14 | if (repositories.length === 0) continue; 15 | 16 | const octokit = new Octokit(); 17 | const q = `${repositories 18 | .map((r) => "repo:" + r.uri.split("/")[3] + "/" + r.uri.split("/")[4]) 19 | .join(" ")} state:open type:pr review-requested:${ 20 | maintainer.github 21 | } user-review-requested:${maintainer.github}`; 22 | 23 | const { data } = await octokit.rest.search.issuesAndPullRequests({ q }); 24 | if (data.total_count === 0) continue; 25 | 26 | text += `\nYou have ${data.total_count} pull requests that need your review:\n`; 27 | data.items.forEach((item) => { 28 | text += `\n• ${item.title} (${item.html_url})`; 29 | }); 30 | 31 | await slack.client.chat.postMessage({ channel: maintainer.slack, text }); 32 | } 33 | } catch (err) { 34 | console.log("🚨🚨 Error in review requests report cron job 🚨🚨"); 35 | console.error(err); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /cron/unsnooze.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { slack } from ".."; 3 | import prisma from "../lib/db"; 4 | 5 | // Runs every day at 12:00 AM 6 | export const unsnoozeCron = async () => { 7 | console.log("⏳⏳ Running unsnooze cron job ⏳⏳"); 8 | try { 9 | const items = await prisma.actionItem.findMany({ 10 | where: { snoozedUntil: { not: null }, status: "open" }, 11 | include: { 12 | snoozedBy: true, 13 | assignee: true, 14 | githubItems: { select: { repository: true, number: true } }, 15 | slackMessages: { select: { channel: true, ts: true } }, 16 | }, 17 | }); 18 | 19 | for await (const item of items) { 20 | const snoozedUntil = dayjs(item.snoozedUntil); 21 | const now = dayjs(); 22 | const diff = now.diff(snoozedUntil, "hour", true).toFixed(2); 23 | 24 | if (snoozedUntil.isAfter(now) || parseFloat(diff) >= 1) continue; 25 | 26 | const url = 27 | item.githubItems.length > 0 28 | ? `${item.githubItems.at(-1)?.repository.url}/issues/${item.githubItems.at(-1)?.number}` 29 | : `https://hackclub.slack.com/archives/${ 30 | item.slackMessages.at(-1)?.channel?.slackId 31 | }/p${item.slackMessages.at(-1)?.ts.replace(".", "")}`; 32 | 33 | await slack.client.chat.postMessage({ 34 | channel: item.snoozedBy?.slackId ?? "", 35 | text: `:wave: Hey, we unsnoozed <${url}|${item.id}> for you. Feel free to pick it up again!`, 36 | }); 37 | } 38 | } catch (err) { 39 | console.log("🚨🚨 Error in unsnooze cron job 🚨🚨"); 40 | console.error(err); 41 | } 42 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slacker", 3 | "version": "1.0.0", 4 | "repository": "git@github.com:hackclub/slacker.git", 5 | "author": "Faisal Sayed ", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "tsx index.ts", 9 | "dev": "tsx watch index.ts", 10 | "db:generate": "npx prisma generate", 11 | "db:migrate-dev": "npx prisma migrate dev", 12 | "db:migrate-deploy": "npx prisma migrate deploy", 13 | "db:push": "npx prisma db push" 14 | }, 15 | "dependencies": { 16 | "@bufbuild/buf": "^1.27.0", 17 | "@bufbuild/protobuf": "^1.3.3", 18 | "@bufbuild/protoc-gen-es": "^1.3.3", 19 | "@connectrpc/connect": "^1.1.2", 20 | "@connectrpc/connect-express": "^1.1.2", 21 | "@connectrpc/connect-node": "^1.1.2", 22 | "@connectrpc/protoc-gen-connect-es": "^1.1.2", 23 | "@elastic/elasticsearch": "^8.10.0", 24 | "@octokit/auth-app": "^6.0.1", 25 | "@octokit/webhooks": "^12.0.8", 26 | "@prisma/client": "^5.4.2", 27 | "@slack/bolt": "^3.14.0", 28 | "@slack/oauth": "^2.6.1", 29 | "closest-match": "^1.3.3", 30 | "dayjs": "^1.11.7", 31 | "dotenv": "^16.3.1", 32 | "express": "^4.19.2", 33 | "js-yaml": "^4.1.0", 34 | "node-cron": "^3.0.3", 35 | "node-statsd": "^0.1.1", 36 | "octokit": "^3.1.2", 37 | "prisma": "^5.4.2", 38 | "response-time": "^2.3.2", 39 | "tsx": "^3.12.6", 40 | "zod": "^3.22.4" 41 | }, 42 | "devDependencies": { 43 | "@types/js-yaml": "^4.0.7", 44 | "@types/node": "^18.16.0", 45 | "@types/node-cron": "^3.0.11", 46 | "@types/response-time": "^2.3.8", 47 | "eslint": "^8.39.0", 48 | "ts-node-dev": "^2.0.0", 49 | "typescript": "^5.0.4" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /actions/resolve.ts: -------------------------------------------------------------------------------- 1 | import { ActionHandler } from "."; 2 | import prisma from "../lib/db"; 3 | import metrics from "../lib/metrics"; 4 | 5 | export const resolve: ActionHandler = async ({ ack, body, client, logger }) => { 6 | await ack(); 7 | 8 | try { 9 | const { actions, channel, message } = body as any; 10 | const actionId = actions[0].value; 11 | 12 | const action = await prisma.actionItem.findFirst({ where: { id: actionId } }); 13 | if (!action) return; 14 | 15 | await client.views.open({ 16 | trigger_id: (body as any).trigger_id as string, 17 | view: { 18 | type: "modal", 19 | callback_id: "resolve_submit", 20 | private_metadata: JSON.stringify({ 21 | actionId, 22 | channelId: channel?.id as string, 23 | messageId: message.ts, 24 | }), 25 | title: { type: "plain_text", text: "Resolve Action Item" }, 26 | submit: { type: "plain_text", text: "Submit" }, 27 | blocks: [ 28 | { 29 | type: "input", 30 | block_id: "reason", 31 | element: { type: "plain_text_input", action_id: "reason-action", multiline: true }, 32 | label: { type: "plain_text", text: "Why is this resolved?" }, 33 | }, 34 | { 35 | type: "context", 36 | elements: [ 37 | { 38 | type: "mrkdwn", 39 | text: `:bangbang: Resolving an item will close it and remove it from the list.`, 40 | }, 41 | ], 42 | }, 43 | ], 44 | }, 45 | }); 46 | 47 | metrics.increment("slack.resolve.open", 1); 48 | } catch (err) { 49 | metrics.increment("errors.slack.resolve", 1); 50 | logger.error(err); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /actions/irrelevant.ts: -------------------------------------------------------------------------------- 1 | import { ActionHandler } from "."; 2 | import prisma from "../lib/db"; 3 | import metrics from "../lib/metrics"; 4 | 5 | export const markIrrelevant: ActionHandler = async ({ ack, body, client, logger }) => { 6 | await ack(); 7 | 8 | try { 9 | const { actions, channel, message } = body as any; 10 | const actionId = actions[0].value; 11 | 12 | const action = await prisma.actionItem.findFirst({ where: { id: actionId } }); 13 | if (!action) return; 14 | 15 | await client.views.open({ 16 | trigger_id: (body as any).trigger_id as string, 17 | view: { 18 | type: "modal", 19 | callback_id: "irrelevant_submit", 20 | private_metadata: JSON.stringify({ 21 | actionId, 22 | channelId: channel?.id as string, 23 | messageId: message.ts, 24 | }), 25 | title: { type: "plain_text", text: "Mark as Irrelevant" }, 26 | submit: { type: "plain_text", text: "Submit" }, 27 | blocks: [ 28 | { 29 | type: "input", 30 | block_id: "reason", 31 | element: { type: "plain_text_input", action_id: "reason-action", multiline: true }, 32 | label: { type: "plain_text", text: "Why is this irrelevant?" }, 33 | }, 34 | { 35 | type: "context", 36 | elements: [ 37 | { 38 | type: "mrkdwn", 39 | text: `:bangbang: Marking an item as irrelevant will close it and remove it from the list.`, 40 | }, 41 | ], 42 | }, 43 | ], 44 | }, 45 | }); 46 | metrics.increment("slack.mark_irrelevant.open", 1); 47 | } catch (err) { 48 | metrics.increment("errors.slack.mark_irrelevant", 1); 49 | logger.error(err); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /prisma/migrations/20240125185626_follow_ups/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `FollowUp` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | - You are about to drop the column `actionItemId` on the `FollowUp` table. All the data in the column will be lost. 6 | - You are about to drop the column `userId` on the `FollowUp` table. All the data in the column will be lost. 7 | - Added the required column `nextItemId` to the `FollowUp` table without a default value. This is not possible if the table is not empty. 8 | - Added the required column `parentId` to the `FollowUp` table without a default value. This is not possible if the table is not empty. 9 | 10 | */ 11 | -- AlterEnum 12 | ALTER TYPE "ActionStatus" ADD VALUE 'followUp'; 13 | 14 | -- DropForeignKey 15 | ALTER TABLE "FollowUp" DROP CONSTRAINT "FollowUp_actionItemId_fkey"; 16 | 17 | -- DropForeignKey 18 | ALTER TABLE "FollowUp" DROP CONSTRAINT "FollowUp_userId_fkey"; 19 | 20 | -- DropIndex 21 | DROP INDEX "FollowUp_actionItemId_idx"; 22 | 23 | -- DropIndex 24 | DROP INDEX "FollowUp_userId_idx"; 25 | 26 | -- AlterTable 27 | ALTER TABLE "FollowUp" DROP CONSTRAINT "FollowUp_pkey", 28 | DROP COLUMN "actionItemId", 29 | DROP COLUMN "userId", 30 | ADD COLUMN "nextItemId" TEXT NOT NULL, 31 | ADD COLUMN "parentId" TEXT NOT NULL, 32 | ADD CONSTRAINT "FollowUp_pkey" PRIMARY KEY ("parentId", "nextItemId"); 33 | 34 | -- CreateIndex 35 | CREATE INDEX "FollowUp_parentId_idx" ON "FollowUp"("parentId"); 36 | 37 | -- CreateIndex 38 | CREATE INDEX "FollowUp_nextItemId_idx" ON "FollowUp"("nextItemId"); 39 | 40 | -- AddForeignKey 41 | ALTER TABLE "FollowUp" ADD CONSTRAINT "FollowUp_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "ActionItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; 42 | 43 | -- AddForeignKey 44 | ALTER TABLE "FollowUp" ADD CONSTRAINT "FollowUp_nextItemId_fkey" FOREIGN KEY ("nextItemId") REFERENCES "ActionItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; 45 | -------------------------------------------------------------------------------- /actions/notes.ts: -------------------------------------------------------------------------------- 1 | import { Block } from "@slack/bolt"; 2 | import { ActionHandler } from "."; 3 | import prisma from "../lib/db"; 4 | import metrics from "../lib/metrics"; 5 | 6 | export const notes: ActionHandler = async ({ ack, body, client, logger }) => { 7 | await ack(); 8 | 9 | try { 10 | const { actions } = body as any; 11 | const actionId = actions[0].value; 12 | const action = await prisma.actionItem.findFirst({ where: { id: actionId } }); 13 | if (!action) return; 14 | 15 | await client.views.open({ 16 | trigger_id: (body as any).trigger_id as string, 17 | view: { 18 | type: "modal", 19 | callback_id: "notes_submit", 20 | private_metadata: JSON.stringify({ actionId }), 21 | title: { 22 | type: "plain_text", 23 | text: "Notes", 24 | }, 25 | submit: { 26 | type: "plain_text", 27 | text: "Submit", 28 | }, 29 | blocks: [ 30 | { 31 | type: "input", 32 | block_id: "notes", 33 | optional: true, 34 | element: { 35 | type: "plain_text_input", 36 | action_id: "notes-action", 37 | multiline: true, 38 | initial_value: action.notes, 39 | }, 40 | label: { 41 | type: "plain_text", 42 | text: "Add notes for this action item", 43 | }, 44 | }, 45 | ...(action.reason.length > 0 46 | ? [ 47 | { 48 | type: "section", 49 | text: { 50 | type: "plain_text", 51 | text: `**Reason:** ${action.reason}`, 52 | emoji: true, 53 | }, 54 | } as Block, 55 | ] 56 | : []), 57 | ], 58 | }, 59 | }); 60 | 61 | metrics.increment("slack.notes.open", 1); 62 | } catch (err) { 63 | metrics.increment("errors.slack.notes", 1); 64 | logger.error(err); 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /cron/unassign.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { slack } from ".."; 3 | import prisma from "../lib/db"; 4 | import { indexDocument } from "../lib/elastic"; 5 | 6 | // Runs every day at 12:00 AM 7 | export const unassignCron = async () => { 8 | console.log("⏳⏳ Running unassign cron job ⏳⏳"); 9 | 10 | try { 11 | const items = await prisma.actionItem 12 | .findMany({ 13 | where: { 14 | assigneeId: { not: null }, 15 | assignedOn: { not: null }, 16 | status: "open", 17 | }, 18 | include: { 19 | assignee: true, 20 | githubItems: { select: { repository: true, number: true } }, 21 | slackMessages: { select: { channel: true, ts: true } }, 22 | }, 23 | }) 24 | .then((res) => 25 | res.filter( 26 | (item) => item.snoozedUntil === null || dayjs(item.snoozedUntil).isBefore(dayjs()) 27 | ) 28 | ); 29 | 30 | for await (const item of items) { 31 | const assignedOn = dayjs(item.snoozedUntil || item.assignedOn); 32 | let deadline = assignedOn; 33 | 34 | let count = 0; 35 | while (count < 2) { 36 | deadline = deadline.add(1, "day"); 37 | if (deadline.day() !== 0 && deadline.day() !== 6) count++; 38 | } 39 | 40 | if (dayjs().isBefore(deadline)) continue; 41 | await prisma.actionItem.update({ where: { id: item.id }, data: { assigneeId: null } }); 42 | 43 | const url = 44 | item.githubItems.length > 0 45 | ? `${item.githubItems.at(-1)?.repository.url}/issues/${item.githubItems.at(-1)?.number}` 46 | : `https://hackclub.slack.com/archives/${ 47 | item.slackMessages.at(-1)?.channel?.slackId 48 | }/p${item.slackMessages.at(-1)?.ts.replace(".", "")}`; 49 | 50 | await slack.client.chat.postMessage({ 51 | channel: item.assignee?.slackId ?? "", 52 | text: `:warning: Hey, we unassigned <${url}|${item.id}> from you because you didn't resolve it in time. Feel free to pick it up again!`, 53 | }); 54 | 55 | await indexDocument(item.id); 56 | } 57 | } catch (err) { 58 | console.log("🚨🚨 Error in unassign cron job 🚨🚨"); 59 | console.error(err); 60 | } 61 | }; -------------------------------------------------------------------------------- /text-analysis.ts: -------------------------------------------------------------------------------- 1 | // Analyze texts here: 2 | import { ActionStatus } from "@prisma/client"; 3 | import prisma from "./lib/db"; 4 | 5 | async function analyze() { 6 | const items = await prisma.actionItem.findMany({ 7 | select: { 8 | githubItem: { select: { body: true, title: true } }, 9 | slackMessage: { select: { text: true } }, 10 | status: true, 11 | flag: true, 12 | }, 13 | where: { status: ActionStatus.closed }, 14 | }); 15 | 16 | const resolved = { 17 | count: 0, 18 | avgCharacterCount: 0, 19 | avgWords: 0, 20 | avgQuestionMarks: 0, 21 | }; 22 | 23 | const irrelevant = { 24 | count: 0, 25 | avgCharacterCount: 0, 26 | avgWords: 0, 27 | avgQuestionMarks: 0, 28 | }; 29 | 30 | for (const item of items) { 31 | const text = item.slackMessage?.text || item.githubItem?.title || item.githubItem?.body || ""; 32 | const characterCount = text.length; 33 | const words = text.split(" ").length; 34 | const questionMarks = text.split("?").length - 1; 35 | 36 | if (item.flag === "irrelevant") { 37 | irrelevant.count++; 38 | irrelevant.avgCharacterCount += characterCount; 39 | irrelevant.avgWords += words; 40 | irrelevant.avgQuestionMarks += questionMarks; 41 | } 42 | 43 | if (item.flag !== "irrelevant" && item.status === ActionStatus.closed) { 44 | resolved.count++; 45 | resolved.avgCharacterCount += characterCount; 46 | resolved.avgWords += words; 47 | resolved.avgQuestionMarks += questionMarks; 48 | } 49 | } 50 | 51 | resolved.avgCharacterCount = resolved.avgCharacterCount / resolved.count; 52 | resolved.avgWords = resolved.avgWords / resolved.count; 53 | resolved.avgQuestionMarks = resolved.avgQuestionMarks / resolved.count; 54 | 55 | irrelevant.avgCharacterCount = irrelevant.avgCharacterCount / irrelevant.count; 56 | irrelevant.avgWords = irrelevant.avgWords / irrelevant.count; 57 | irrelevant.avgQuestionMarks = irrelevant.avgQuestionMarks / irrelevant.count; 58 | 59 | console.log("Here are the results of the analysis: "); 60 | console.log("Resolved: ", JSON.stringify(resolved, null, 2)); 61 | console.log("Irrelevant: ", JSON.stringify(irrelevant, null, 2)); 62 | } 63 | 64 | export default analyze; 65 | -------------------------------------------------------------------------------- /gen/eliza_connect.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-connect-es v1.1.2 with parameter "target=ts" 2 | // @generated from file eliza.proto (package connectrpc.eliza.v1, syntax proto3) 3 | /* eslint-disable */ 4 | // @ts-nocheck 5 | 6 | import { ActionItemRequest, AssignRequest, DelayRequest, Empty, NoteRequest, Response, SlackActionItemRequest, SlackActionItemResponse } from "./eliza_pb.js"; 7 | import { MethodKind } from "@bufbuild/protobuf"; 8 | 9 | /** 10 | * @generated from service connectrpc.eliza.v1.ElizaService 11 | */ 12 | export const ElizaService = { 13 | typeName: "connectrpc.eliza.v1.ElizaService", 14 | methods: { 15 | /** 16 | * @generated from rpc connectrpc.eliza.v1.ElizaService.SyncGithubItems 17 | */ 18 | syncGithubItems: { 19 | name: "SyncGithubItems", 20 | I: Empty, 21 | O: Response, 22 | kind: MethodKind.Unary, 23 | }, 24 | /** 25 | * @generated from rpc connectrpc.eliza.v1.ElizaService.AssignActionItem 26 | */ 27 | assignActionItem: { 28 | name: "AssignActionItem", 29 | I: AssignRequest, 30 | O: Response, 31 | kind: MethodKind.Unary, 32 | }, 33 | /** 34 | * @generated from rpc connectrpc.eliza.v1.ElizaService.ResolveActionItem 35 | */ 36 | resolveActionItem: { 37 | name: "ResolveActionItem", 38 | I: ActionItemRequest, 39 | O: Response, 40 | kind: MethodKind.Unary, 41 | }, 42 | /** 43 | * @generated from rpc connectrpc.eliza.v1.ElizaService.IrrelevantActionItem 44 | */ 45 | irrelevantActionItem: { 46 | name: "IrrelevantActionItem", 47 | I: ActionItemRequest, 48 | O: Response, 49 | kind: MethodKind.Unary, 50 | }, 51 | /** 52 | * @generated from rpc connectrpc.eliza.v1.ElizaService.UpdateNotes 53 | */ 54 | updateNotes: { 55 | name: "UpdateNotes", 56 | I: NoteRequest, 57 | O: Response, 58 | kind: MethodKind.Unary, 59 | }, 60 | /** 61 | * @generated from rpc connectrpc.eliza.v1.ElizaService.SnoozeActionItem 62 | */ 63 | snoozeActionItem: { 64 | name: "SnoozeActionItem", 65 | I: DelayRequest, 66 | O: Response, 67 | kind: MethodKind.Unary, 68 | }, 69 | /** 70 | * @generated from rpc connectrpc.eliza.v1.ElizaService.FollowUpActionItem 71 | */ 72 | followUpActionItem: { 73 | name: "FollowUpActionItem", 74 | I: DelayRequest, 75 | O: Response, 76 | kind: MethodKind.Unary, 77 | }, 78 | /** 79 | * @generated from rpc connectrpc.eliza.v1.ElizaService.GetSlackActionItem 80 | */ 81 | getSlackActionItem: { 82 | name: "GetSlackActionItem", 83 | I: SlackActionItemRequest, 84 | O: SlackActionItemResponse, 85 | kind: MethodKind.Unary, 86 | }, 87 | } 88 | } as const; 89 | 90 | -------------------------------------------------------------------------------- /cron/followUp.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { slack } from ".."; 3 | import { buttons, githubItem, slackItem } from "../lib/blocks"; 4 | import prisma from "../lib/db"; 5 | 6 | // Runs every day at 12:00 AM 7 | export const followUpCron = async () => { 8 | console.log("⏳⏳ Running follow up cron job ⏳⏳"); 9 | try { 10 | const followUps = await prisma.followUp.findMany({ 11 | include: { 12 | parent: { 13 | include: { 14 | githubItems: { include: { author: true, repository: true } }, 15 | slackMessages: { include: { author: true, channel: true } }, 16 | participants: { include: { user: true } }, 17 | assignee: true, 18 | }, 19 | }, 20 | nextItem: { include: { assignee: true } }, 21 | }, 22 | }); 23 | 24 | for await (const followUp of followUps) { 25 | const now = dayjs(); 26 | const followUpOn = dayjs(followUp.date); 27 | const diff = parseFloat(now.diff(followUpOn, "hour", true).toFixed(2)); 28 | 29 | if (followUpOn.isAfter(now) || diff >= 1) continue; 30 | 31 | const url = 32 | followUp.parent.githubItems.length > 0 33 | ? `${followUp.parent.githubItems.at(-1)?.repository.url}/issues/${ 34 | followUp.parent.githubItems.at(-1)?.number 35 | }` 36 | : `https://hackclub.slack.com/archives/${ 37 | followUp.parent.slackMessages.at(-1)?.channel?.slackId 38 | }/p${followUp.parent.slackMessages.at(-1)?.ts.replace(".", "")}`; 39 | 40 | await prisma.followUp.update({ 41 | where: { 42 | parentId_nextItemId: { parentId: followUp.parentId, nextItemId: followUp.nextItemId }, 43 | }, 44 | data: { nextItem: { update: { status: "open" } } }, 45 | }); 46 | 47 | const followUpDuration = dayjs(followUp.date).diff( 48 | followUp.parent.resolvedAt ?? followUp.createdAt, 49 | "day" 50 | ); 51 | 52 | await slack.client.chat.postMessage({ 53 | channel: followUp.nextItem.assignee?.slackId ?? "", 54 | text: `:wave: Hey, you asked us to follow up on <${url}|${followUp.parent.id}>. Take a look at it again!`, 55 | blocks: [ 56 | { 57 | type: "section", 58 | text: { 59 | type: "mrkdwn", 60 | text: `:wave: Hey, you asked us to follow up on <${url}|${followUp.parent.id}>. Take a look at it again!`, 61 | }, 62 | }, 63 | ...(followUp.parent.githubItems.length > 0 64 | ? [ 65 | githubItem({ 66 | item: followUp.parent, 67 | followUp: { id: followUp.nextItemId, duration: followUpDuration }, 68 | }), 69 | ] 70 | : followUp.parent.slackMessages.length > 0 71 | ? [ 72 | slackItem({ 73 | item: followUp.parent, 74 | followUp: { id: followUp.nextItemId, duration: followUpDuration }, 75 | }), 76 | ] 77 | : []), 78 | ...buttons({ 79 | item: followUp.parent, 80 | showAssignee: true, 81 | showActions: true, 82 | followUpId: followUp.nextItemId, 83 | }), 84 | ], 85 | }); 86 | } 87 | } catch (err) { 88 | console.log("🚨🚨 Error in follow up cron job 🚨🚨"); 89 | console.error(err); 90 | } 91 | }; -------------------------------------------------------------------------------- /maintainers.yaml: -------------------------------------------------------------------------------- 1 | - id: graham 2 | slack: U04QH1TTMBP 3 | github: grymmy 4 | 5 | - id: max 6 | slack: U0C7B14Q3 7 | github: maxwofford 8 | 9 | - id: faisal 10 | slack: U014ND5P1N2 11 | github: faisalsayed10 12 | 13 | - id: sam 14 | slack: USNPNJXNX 15 | github: sampoder 16 | 17 | - id: caleb 18 | slack: U013B6CPV62 19 | github: cjdenio 20 | 21 | - id: josias 22 | slack: U01PJ08PR7S 23 | github: JosiasAurel 24 | 25 | - id: shawn 26 | slack: U04BBP8H9FA 27 | github: Sheepy3 28 | 29 | - id: leo 30 | slack: U022FMN61SB 31 | github: leomcelroy 32 | 33 | - id: lucas 34 | slack: U040N4ESCEL 35 | github: LucasHT22 36 | 37 | - id: thomas 38 | slack: U041FQB8VK2 39 | github: serenityUX 40 | 41 | - id: arpan 42 | slack: U0409FSKU82 43 | github: Arpan-206 44 | 45 | - id: sahiti 46 | slack: U03RU99SGKA 47 | github: sahitid 48 | 49 | - id: toby 50 | slack: U02C9DQ7ZL2 51 | github: developedbytoby 52 | 53 | - id: zrl 54 | slack: U0266FRGP 55 | github: zachlatta 56 | 57 | - id: V205 58 | slack: U05QJ4CF5QT 59 | github: V205Arduino 60 | 61 | - id: kara 62 | slack: U032A2PMSE9 63 | github: karamassie 64 | 65 | - id: arav 66 | slack: U01MPHKFZ7S 67 | github: tregsthedev 68 | 69 | - id: sean 70 | slack: U04FATFRE6T 71 | github: devramsean0 72 | 73 | - id: chris 74 | slack: UDK5M9Y13 75 | github: polytroper 76 | 77 | - id: elliot 78 | slack: U041DAECHHA 79 | github: Captainexpo-1 80 | 81 | - id: shubham 82 | slack: U014E8132DB 83 | github: DevIos01 84 | 85 | - id: shubhampatil 86 | slack: U029D5FG8EN 87 | github: ShubhamPatilsd 88 | 89 | - id: hugo 90 | slack: U017EPB6LE9 91 | github: Hugoyhu 92 | 93 | - id: dev 94 | slack: U0261EB1EG7 95 | github: devenjadhav 96 | 97 | - id: sarthak 98 | slack: U0161JDSHGR 99 | github: sarthaktexas 100 | 101 | - id: kevin 102 | slack: U015X5P6KAM 103 | github: kvnyng 104 | 105 | - id: karmanyaah 106 | slack: U04CNFV0T4M 107 | github: karmanyaahm 108 | 109 | - id: gaurav 110 | slack: U043Q05KFAA 111 | github: yednapg 112 | 113 | - id: jasper 114 | slack: U05NX48GL3T 115 | github: jaspermayone 116 | 117 | - id: sarah 118 | slack: U06L79991V0 119 | github: sedowden 120 | 121 | - id: marios 122 | slack: U04FMKCVASJ 123 | github: GalaxyGamingBoy 124 | 125 | - id: christina 126 | slack: UT2E7L19C 127 | github: christinaasquith 128 | 129 | - id: kaleo 130 | slack: U033N1Y3YTB 131 | github: VelocityDesign 132 | 133 | - id: alex 134 | slack: U04MDFEBL2U 135 | github: ajs256 136 | 137 | - id: zoya 138 | slack: U016S3C7JS2 139 | github: zoya-hussain 140 | 141 | - id: reese 142 | slack: U0162MDUP7C 143 | github: reesericci 144 | 145 | - id: prophetorpheus 146 | slack: U0000000000 147 | github: prophetorpheus 148 | 149 | - id: savina 150 | slack: U05GRNG7BNE 151 | github: savinajabbo 152 | 153 | - id: khaleel 154 | slack: U032NMP21L1 155 | github: khalby786 156 | 157 | - id: snwy 158 | slack: U03V4686P9N 159 | github: snoglobe 160 | 161 | - id: aileen 162 | slack: U036N1SDEPQ 163 | github: aileencrivera 164 | 165 | - id: vivian 166 | slack: U06CJNE9U02 167 | github: vvireless 168 | 169 | - id: ryan 170 | slack: U04KNK837S4 171 | github: whatwareweb 172 | 173 | - id: arthur 174 | slack: D06E6KVDKPC 175 | github: AverseABFun 176 | 177 | - id: cskartikey 178 | slack: U05F4B48GBF 179 | github: cskartikey 180 | 181 | - id: nikos 182 | slack: U04N415FE4T 183 | github: Nikos1508 184 | 185 | - id: srijit 186 | slack: U03VCC3AJCA 187 | github: devsrijit 188 | -------------------------------------------------------------------------------- /actions/delay.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { ActionHandler } from "."; 3 | import prisma from "../lib/db"; 4 | import metrics from "../lib/metrics"; 5 | 6 | export const followUp: ActionHandler = async (args) => { 7 | await args.ack(); 8 | await snooze(args); 9 | }; 10 | 11 | export const snooze: ActionHandler = async ({ ack, body, client, logger }) => { 12 | const { actions, channel, message } = body as any; 13 | 14 | try { 15 | const actionId = actions[0].value; 16 | actions[0].action_id === "snooze" && (await ack()); 17 | 18 | const action = await prisma.actionItem.findFirst({ where: { id: actionId } }); 19 | if (!action) return; 20 | 21 | let nextBusinessDay = dayjs().add(1, "day"); 22 | if (nextBusinessDay.day() === 0) nextBusinessDay = nextBusinessDay.add(1, "day"); 23 | else if (nextBusinessDay.day() === 6) nextBusinessDay = nextBusinessDay.add(2, "day"); 24 | 25 | const initial_date_time = Math.floor( 26 | nextBusinessDay.hour(12).minute(0).second(0).millisecond(0).valueOf() / 1000 27 | ); 28 | 29 | await client.views.open({ 30 | trigger_id: (body as any).trigger_id as string, 31 | view: { 32 | type: "modal", 33 | callback_id: "snooze_submit", 34 | private_metadata: JSON.stringify({ 35 | actionId, 36 | channelId: channel?.id as string, 37 | messageId: message.ts, 38 | }), 39 | title: { 40 | type: "plain_text", 41 | text: actions[0].action_id === "snooze" ? "Snooze" : "Follow Up", 42 | }, 43 | submit: { 44 | type: "plain_text", 45 | text: actions[0].action_id === "snooze" ? "Snooze" : "Follow Up", 46 | }, 47 | blocks: [ 48 | { 49 | type: "input", 50 | block_id: "datetime", 51 | element: { 52 | type: "datetimepicker", 53 | action_id: "datetimepicker-action", 54 | initial_date_time, 55 | focus_on_load: true, 56 | }, 57 | label: { 58 | type: "plain_text", 59 | text: actions[0].action_id === "snooze" ? "Snooze until" : "Follow up on", 60 | }, 61 | }, 62 | { 63 | type: "input", 64 | block_id: "reason", 65 | element: { 66 | type: "plain_text_input", 67 | action_id: "reason-action", 68 | multiline: true, 69 | }, 70 | label: { 71 | type: "plain_text", 72 | text: "Why?", 73 | }, 74 | }, 75 | { 76 | type: "context", 77 | elements: [ 78 | { 79 | type: "mrkdwn", 80 | text: 81 | actions[0].action_id === "snooze" 82 | ? `:bangbang: Snooze wisely. If you keep snoozing an item repeatedly, you'll be called out for slackin'.` 83 | : `You can only follow up on an item once. If you need to follow up again, you can do so once the first follow up has been completed.`, 84 | }, 85 | ], 86 | }, 87 | ], 88 | }, 89 | }); 90 | 91 | actions[0].action_id === "snooze" 92 | ? metrics.increment("slack.snooze.open", 1) 93 | : metrics.increment("slack.follow_up.open", 1); 94 | } catch (err) { 95 | actions[0].action_id === "snooze" 96 | ? metrics.increment("errors.slack.snooze", 1) 97 | : metrics.increment("errors.slack.follow_up", 1); 98 | logger.error(err); 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is Slacker? 2 | At Hack Club, we have a lot of different projects that need ownership over the long-term. Each project might encompass many git repositories and Slack channels that need various different levels of support. Slacker is an attempt to organize and systematize our developer/customer support use cases into something that is easy to manage and measure, and also is welcoming of newcomers wanting to help participate on projects. 3 | 4 | # Project scope 5 | * Its primary purpose is to match incoming work with correct project maintainers 6 | * It is Github and Slack aware, which are both avenues through which work is submitted to us in practice @ HC 7 | * It is a system designed such that we do not drop balls (ignore and fail to triage incoming work), and will measure this directly via a time-series document trail written to ElasticSearch 8 | * Also aims to invite people to join teams and gain responsibilities, advertising them via Slack and web interfaces 9 | * Will (semi or fully) automate work where possible 10 | 11 | # How do I use it? 12 | Currently, the main interface to Slacker is via issuing slack bot commands. A good first step to to run ```/slacker help``` to generally see its capabilities - but generally, one takes the following process: 13 | 1. Assign yourself to a project via a project in the [config directory](https://github.com/hackclub/slacker/tree/main/config). 14 |
**_NOTE:_** _You must be entered into the [maintainers config](https://github.com/hackclub/slacker/blob/main/maintainers.yaml) to be assigned to a project._ 15 | 16 | 2. Run ```/slacker whatsup```, which shows all of the projects you're currently assigned to. 17 | 18 | 19 | 20 | 3. Run ```/slacker gimme``` to self-assign the next work item. After you investigate the AI, you can click 'Resolve' to signify the issue has been triaged successfully, 'Close - Irrelevant' to mark the issue as not needing action, or 'Snooze', where you can specify a time in the future to remind maintainers to triage this issue. 21 | 22 | 23 | 24 | 4. If you need to regain context on your current work, run ```/slacker me```. This will display all work items that are currently assigned to you. 25 | 26 | # Can it support my project? 27 | In all likelihood, yes. Just add yourself and your project members to the global [maintainers configuration file](https://github.com/hackclub/slacker/blob/main/maintainers.yaml), and add a new project config [here](https://github.com/hackclub/slacker/tree/main/config). Here is an example from the Sprig project - it is fairly self-explanatory... 28 | ``` 29 | name: Sprig 30 | description: Teens make games and get a Sprig 31 | maintainers: [leo, lucas, kognise, max, graham, josias, shawn] 32 | channels: 33 | - name: sprig 34 | id: C02UN35M7LG 35 | sla: 36 | responseTime: 1h 37 | - name: sprig-platform 38 | id: C04S1A8NT44 39 | sla: 40 | responseTime: 24h 41 | - name: sprig-device-requests 42 | id: C063DFZ532M 43 | sla: 44 | responseTime: 24h 45 | repos: 46 | - uri: https://github.com/hackclub/sprig 47 | sla: 48 | responseTime: 1h 49 | - uri: https://github.com/hackclub/spade 50 | sla: 51 | responseTime: 24h 52 | ``` 53 | 54 | # Backend architecture 55 | 56 | 57 | # How can I learn more? 58 | Feel free to visit the [#hq-engineering](https://hackclub.slack.com/archives/C05SVRTCDGV) Hack Club Slack channel and ask away! 59 | -------------------------------------------------------------------------------- /events/message.ts: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client"; 2 | import { Middleware, SlackEventMiddlewareArgs } from "@slack/bolt"; 3 | import dayjs from "dayjs"; 4 | import prisma from "../lib/db"; 5 | import { indexDocument } from "../lib/elastic"; 6 | import metrics from "../lib/metrics"; 7 | import { checkNeedsNotifying, getProjectName, getYamlFile, syncParticipants } from "../lib/utils"; 8 | import { 9 | checkMessageConditions, 10 | createGroupedMessage, 11 | createNewMessage, 12 | ParentMessage, 13 | updateExistingMessage, 14 | } from "./helpers"; 15 | 16 | type MessageEvent = Middleware>; 17 | 18 | export const messageEvent: MessageEvent = async ({ message, client, logger }) => { 19 | try { 20 | const { channel, parent } = await checkMessageConditions(message); 21 | if (!channel || !parent) return; 22 | 23 | const parentInDb = await prisma.slackMessage.findFirst({ 24 | where: { ts: parent.ts, channel: { slackId: message.channel } }, 25 | include: { 26 | actionItem: { 27 | include: { 28 | participants: { select: { user: true } }, 29 | slackMessages: { select: { replies: true } }, 30 | }, 31 | }, 32 | }, 33 | }); 34 | 35 | const threadReplies = await client.conversations 36 | .replies({ channel: message.channel, ts: parent.ts as string, limit: 100 }) 37 | .then((res) => res.messages?.slice(1) || []); 38 | 39 | const email = await client.users 40 | .info({ user: parent.user as string }) 41 | .then((res) => res.user?.profile?.email || ""); 42 | 43 | if (parentInDb) { 44 | return await updateExistingMessage(parentInDb, parent, threadReplies); 45 | } else { 46 | const user = await prisma.user.findFirst({ where: { slackId: parent.user as string } }); 47 | let author: User; 48 | 49 | if (!user) 50 | author = await prisma.user.create({ data: { email, slackId: parent.user as string } }); 51 | else author = user; 52 | 53 | const project = getProjectName({ channelId: message.channel }); 54 | const details = getYamlFile(`${project}.yaml`); 55 | const grouping = details.channels?.find((c) => c.id === message.channel)?.grouping || { 56 | minutes: 0, 57 | }; 58 | 59 | const recentSlackMessage = await prisma.slackMessage.findFirst({ 60 | where: { 61 | channel: { slackId: message.channel }, 62 | createdAt: { gte: dayjs().subtract(grouping.minutes, "minute").toDate() }, 63 | actionItem: { status: "open", assigneeId: null }, 64 | }, 65 | include: { actionItem: { select: { id: true } } }, 66 | orderBy: { createdAt: "desc" }, 67 | }); 68 | 69 | let slackMessage: ParentMessage | undefined; 70 | const createProps = { recentSlackMessage, parent, threadReplies, message, author }; 71 | 72 | if (recentSlackMessage && grouping.minutes > 0) { 73 | slackMessage = await createGroupedMessage(createProps); 74 | } else { 75 | slackMessage = await createNewMessage(createProps); 76 | } 77 | 78 | if (slackMessage) { 79 | const participants = Array.from( 80 | new Set( 81 | (parent.reply_users || []).concat( 82 | slackMessage.actionItem.participants 83 | .map((p) => p.user.slackId) 84 | .filter((p) => p) as string[] 85 | ) 86 | ) 87 | ); 88 | 89 | await syncParticipants(participants, slackMessage.actionItem.id); 90 | await indexDocument(slackMessage.actionItem.id); 91 | await checkNeedsNotifying(slackMessage.actionItem.id); 92 | } 93 | } 94 | } catch (err) { 95 | metrics.increment("errors.slack.message", 1); 96 | logger.error(err); 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export type Config = { 2 | name: string; 3 | description: string; 4 | maintainers: string[]; 5 | clawback?: boolean; // removes assigned issues from github if not resolved in time 6 | private?: boolean; 7 | channels?: { 8 | grouping?: { minutes: number }; 9 | id: string; 10 | name: string; 11 | notify: string[]; 12 | }[]; 13 | repos: { 14 | uri: string; 15 | notify: string[]; 16 | }[]; 17 | resources?: { 18 | name: string; 19 | uri: string; 20 | }[]; 21 | sections?: { 22 | name: string; 23 | pattern: string; 24 | notify: string[]; 25 | }[]; 26 | }; 27 | 28 | export type Maintainer = { id: string; slack: string; github: string }; 29 | 30 | export type GithubData = { 31 | repository: { 32 | issues: IssueOrPull; 33 | pullRequests: IssueOrPull; 34 | }; 35 | }; 36 | 37 | export type IssueOrPull = { 38 | nodes: { 39 | id: string; 40 | number: number; 41 | title: string; 42 | bodyText: string; 43 | createdAt: string; 44 | updatedAt: string; 45 | author: { 46 | login: string; 47 | }; 48 | labels: { 49 | nodes: { 50 | name: string; 51 | }[]; 52 | }; 53 | assignees: { 54 | nodes: { 55 | login: string; 56 | createdAt: string; 57 | }[]; 58 | }; 59 | participants: { 60 | nodes: { 61 | login: string; 62 | }[]; 63 | }; 64 | comments: { 65 | totalCount: number; 66 | nodes: { 67 | author: { 68 | resourcePath: string; 69 | login: string; 70 | }; 71 | createdAt: string; 72 | }[]; 73 | }; 74 | timelineItems: { edges: { node: { createdAt: string } }[] }; 75 | }[]; 76 | }; 77 | 78 | export type SingleIssueOrPullData = { 79 | node: { 80 | id: string; 81 | number: number; 82 | title: string; 83 | bodyText: string; 84 | closedAt: string; 85 | assignees: { 86 | nodes: { 87 | login: string; 88 | createdAt: string; 89 | }[]; 90 | }; 91 | labels: { 92 | nodes: { 93 | name: string; 94 | }[]; 95 | }; 96 | participants: { 97 | nodes: { 98 | login: string; 99 | }[]; 100 | }; 101 | comments: { 102 | totalCount: number; 103 | nodes: { 104 | author: { 105 | resourcePath: string; 106 | login: string; 107 | }; 108 | createdAt: string; 109 | }[]; 110 | }; 111 | timelineItems: { edges: { node: { createdAt: string } }[] }; 112 | }; 113 | }; 114 | 115 | export enum State { 116 | open = "open", 117 | triaged = "triaged", 118 | resolved = "resolved", 119 | snoozed = "snoozed", 120 | } 121 | 122 | export enum ItemType { 123 | issue = "issue", 124 | pull = "pull", 125 | message = "message", 126 | followUp = "followUp", 127 | } 128 | 129 | export type ElasticDocument = { 130 | id?: string; 131 | author?: { 132 | displayName: string; 133 | github: string | null; 134 | slack: string | null; 135 | }; 136 | state?: State; 137 | project?: string; 138 | source?: string; 139 | actionItemType?: ItemType; 140 | followUpDuration?: number; 141 | followUpTo?: string; 142 | createdTime?: Date; 143 | resolvedTime?: Date | null; 144 | reason?: string; 145 | firstResponseTime?: Date | null; 146 | lastModifiedTime?: Date; 147 | snoozedUntil?: Date | null; 148 | timesSnoozed?: number; 149 | timesReopened?: number; 150 | timesResolved?: number; 151 | timesCommented?: number; 152 | timesAssigned?: number; 153 | firstResponseTimeInS?: number | null; 154 | resolutionTimeInS?: number | null; 155 | assignee?: { 156 | displayName: string; 157 | github: string | null; 158 | slack: string | null; 159 | }; 160 | actors?: { 161 | displayName: string; 162 | github: string | null; 163 | slack: string | null; 164 | }[]; 165 | url?: string; 166 | }; 167 | -------------------------------------------------------------------------------- /api/auth/callback.ts: -------------------------------------------------------------------------------- 1 | import { createOAuthUserAuth } from "@octokit/auth-app"; 2 | import { Request, Response } from "express"; 3 | import { Octokit } from "octokit"; 4 | import { MAINTAINERS } from "../../lib/utils"; 5 | import { slack } from "../.."; 6 | import prisma from "../../lib/db"; 7 | 8 | export const callbackHandler = async (req: Request, res: Response) => { 9 | const { code, state: id, error, error_description } = req.query; 10 | 11 | if (error && error_description) return res.json({ error, error_description }); 12 | if (!code) return res.json({ error: "No code provided" }); 13 | if (!id) return res.json({ error: "No slackId provided" }); 14 | 15 | const auth = createOAuthUserAuth({ 16 | clientId: process.env.GITHUB_CLIENT_ID as string, 17 | clientSecret: process.env.GITHUB_CLIENT_SECRET as string, 18 | code: code as string, 19 | scopes: ["email"], 20 | }); 21 | 22 | const { token } = await auth(); 23 | const octokit = new Octokit({ auth: token }); 24 | const user = await octokit.rest.users.getAuthenticated(); 25 | const maintainer = MAINTAINERS.find((m) => m.slack === id); 26 | 27 | if (maintainer && user.data.login !== maintainer.github) 28 | return res.json({ 29 | error: `We see that you're trying to authenticate as ${user.data.login}, but you're registered as ${maintainer.github} in the config. Please authenticate as ${maintainer.github} instead.`, 30 | }); 31 | 32 | let email = user.data.email; 33 | 34 | if (!email) { 35 | const { user } = await slack.client.users.info({ user: id as string }); 36 | email = user?.profile?.email || ""; 37 | 38 | if (!email) return res.json({ error: "No email found for this user" }); 39 | } 40 | 41 | // find many users with either the same email / username / slackId 42 | const users = await prisma.user.findMany({ 43 | where: { 44 | OR: [ 45 | { email }, 46 | { email: user.data.login }, 47 | { githubUsername: user.data.login }, 48 | { slackId: id.toString().toUpperCase() }, 49 | ], 50 | }, 51 | }); 52 | 53 | if (users.length > 0) { 54 | // all these users need to be merged into one 55 | // save them into one user, connect all the relations to that one user and delete the rest. 56 | const userId = users[0].id; 57 | 58 | await prisma.slackMessage.updateMany({ 59 | where: { authorId: { in: users.map((u) => u.id) } }, 60 | data: { authorId: userId }, 61 | }); 62 | 63 | await prisma.githubItem.updateMany({ 64 | where: { authorId: { in: users.map((u) => u.id) } }, 65 | data: { authorId: userId }, 66 | }); 67 | 68 | await prisma.participant.updateMany({ 69 | where: { userId: { in: users.map((u) => u.id) } }, 70 | data: { userId: userId }, 71 | }); 72 | 73 | await prisma.actionItem.updateMany({ 74 | where: { snoozedById: { in: users.map((u) => u.id) } }, 75 | data: { snoozedById: userId }, 76 | }); 77 | 78 | await prisma.actionItem.updateMany({ 79 | where: { assigneeId: { in: users.map((u) => u.id) } }, 80 | data: { assigneeId: userId }, 81 | }); 82 | 83 | await prisma.user.deleteMany({ 84 | where: { id: { in: users.map((u) => u.id).filter((i) => i !== userId) } }, 85 | }); 86 | 87 | // update the user 88 | await prisma.user.update({ 89 | where: { id: userId }, 90 | data: { 91 | email, 92 | githubUsername: user.data.login, 93 | githubToken: token, 94 | slackId: id.toString().toUpperCase(), 95 | }, 96 | }); 97 | } else { 98 | // create a new user 99 | await prisma.user.create({ 100 | data: { 101 | email, 102 | githubUsername: user.data.login, 103 | githubToken: token, 104 | slackId: id.toString().toUpperCase(), 105 | }, 106 | }); 107 | } 108 | 109 | return res.json({ message: "OAuth successful, hacker! Go ahead and start using slacker!" }); 110 | }; 111 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { expressConnectMiddleware } from "@connectrpc/connect-express"; 2 | import { createNodeMiddleware } from "@octokit/webhooks"; 3 | import { App, ExpressReceiver, LogLevel } from "@slack/bolt"; 4 | import dayjs from "dayjs"; 5 | import customParseFormat from "dayjs/plugin/customParseFormat"; 6 | import minMax from "dayjs/plugin/minMax"; 7 | import relativeTime from "dayjs/plugin/relativeTime"; 8 | import { config } from "dotenv"; 9 | import express from "express"; 10 | import cron from "node-cron"; 11 | import responseTime from "response-time"; 12 | import { 13 | assign, 14 | followUp, 15 | gimmeAgain, 16 | markIrrelevant, 17 | notes, 18 | resolve, 19 | snooze, 20 | unsnooze, 21 | } from "./actions"; 22 | import { authHandler } from "./api/auth"; 23 | import { callbackHandler } from "./api/auth/callback"; 24 | import { indexHandler } from "./api/index"; 25 | import { followUpCron } from "./cron/followUp"; 26 | import { reportCron } from "./cron/report"; 27 | import { reviewCron } from "./cron/review"; 28 | import { unassignCron } from "./cron/unassign"; 29 | import { unsnoozeCron } from "./cron/unsnooze"; 30 | import { messageEvent } from "./events/message"; 31 | import { handleSlackerCommand } from "./lib/commands"; 32 | import metrics from "./lib/metrics"; 33 | import { webhooks } from "./lib/octokit"; 34 | import { checkDuplicateResources, joinChannels } from "./lib/utils"; 35 | import { irrelevantSubmit, notesSubmit, resolveSubmit, snoozeSubmit } from "./lib/views"; 36 | import routes from "./routes"; 37 | 38 | dayjs.extend(relativeTime); 39 | dayjs.extend(customParseFormat); 40 | dayjs.extend(minMax); 41 | config(); 42 | 43 | const app = express(); 44 | app.use(expressConnectMiddleware({ routes })); 45 | app.use(createNodeMiddleware(webhooks)); 46 | app.use( 47 | responseTime((req, res, time) => { 48 | const stat = (req.method + "/" + req.url?.split("/")[1]) 49 | .toLowerCase() 50 | .replace(/[:.]/g, "") 51 | .replace(/\//g, "_"); 52 | const httpCode = res.statusCode; 53 | const timingStatKey = `http.response.${stat}`; 54 | const codeStatKey = `http.response.${stat}.${httpCode}`; 55 | metrics.timing(timingStatKey, time); 56 | metrics.increment(codeStatKey, 1); 57 | }) 58 | ); 59 | 60 | const receiver = new ExpressReceiver({ 61 | signingSecret: process.env.SLACK_SIGNING_SECRET as string, 62 | app, 63 | }); 64 | 65 | export const slack = new App({ 66 | logLevel: LogLevel.INFO, 67 | token: process.env.SLACK_BOT_TOKEN, 68 | receiver, 69 | }); 70 | 71 | app.get("/", indexHandler); 72 | app.get("/auth", authHandler); 73 | app.get("/auth/callback", callbackHandler); 74 | 75 | slack.command("/slacker", handleSlackerCommand); 76 | slack.command("/slacker-dev", handleSlackerCommand); 77 | slack.action("resolve", resolve); 78 | slack.action("snooze", snooze); 79 | slack.action("followup", followUp); 80 | slack.action("unsnooze", unsnooze); 81 | slack.action("irrelevant", markIrrelevant); 82 | slack.action("assigned", assign); 83 | slack.action("notes", notes); 84 | slack.action("gimme_again", gimmeAgain); 85 | slack.view("snooze_submit", snoozeSubmit); 86 | slack.view("notes_submit", notesSubmit); 87 | slack.view("irrelevant_submit", irrelevantSubmit); 88 | slack.view("resolve_submit", resolveSubmit); 89 | slack.event("message", messageEvent); 90 | 91 | cron.schedule("0 * * * *", unassignCron); 92 | cron.schedule("0 * * * *", unsnoozeCron); 93 | cron.schedule("0 * * * *", followUpCron); 94 | cron.schedule("0 12 * * FRI", reportCron, { timezone: "America/New_York" }); 95 | cron.schedule("0 12 * * FRI", reviewCron, { timezone: "America/New_York" }); 96 | 97 | (async () => { 98 | try { 99 | metrics.increment("server.start.increment", 1); 100 | await checkDuplicateResources(); 101 | await slack.start(process.env.PORT || 5000); 102 | await joinChannels(); 103 | // await backFill(); 104 | console.log(`Server running on http://localhost:5000`); 105 | } catch (err) { 106 | metrics.increment("server.start.error", 1); 107 | console.error(err); 108 | } 109 | })(); 110 | -------------------------------------------------------------------------------- /cron/report.ts: -------------------------------------------------------------------------------- 1 | import { ActionStatus } from "@prisma/client"; 2 | import dayjs from "dayjs"; 3 | import { readdirSync } from "fs"; 4 | import { slack } from ".."; 5 | import prisma from "../lib/db"; 6 | import { MAINTAINERS, getYamlFile } from "../lib/utils"; 7 | 8 | // Runs every Friday at 12:00 PM 9 | export const reportCron = async () => { 10 | console.log("⏳⏳ Running status report cron job ⏳⏳"); 11 | try { 12 | for await (const maintainer of MAINTAINERS) { 13 | const files = readdirSync("./config"); 14 | let text = `:wave: Hey ${maintainer.id}, here's your weekly status report!`; 15 | const user = await prisma.user.findFirst({ 16 | where: { OR: [{ slackId: maintainer.slack }, { githubUsername: maintainer.github }] }, 17 | }); 18 | 19 | if (!user || user.optOut) continue; 20 | 21 | for await (const file of files) { 22 | const { maintainers, channels, repos } = getYamlFile(file); 23 | if (!maintainers.includes(maintainer.id)) continue; 24 | 25 | const items = await prisma.actionItem.findMany({ 26 | where: { 27 | OR: [ 28 | channels 29 | ? { 30 | slackMessages: { 31 | some: { 32 | channel: { slackId: { in: channels?.map((c) => c.id) } }, 33 | }, 34 | }, 35 | } 36 | : {}, 37 | repos 38 | ? { 39 | githubItems: { 40 | some: { repository: { url: { in: repos.map((r) => r.uri) } } }, 41 | }, 42 | } 43 | : {}, 44 | ], 45 | }, 46 | include: { slackMessages: true, githubItems: true, assignee: true }, 47 | }); 48 | 49 | const open = items.filter( 50 | (item) => 51 | item.status === "open" && 52 | (item.snoozedUntil === null || dayjs(item.snoozedUntil).isBefore(dayjs())) 53 | ); 54 | const openMessages = open.filter((item) => item.slackMessages.length > 0); 55 | const openPRs = open.filter( 56 | (item) => item.githubItems.filter((i) => i.type === "pull_request").length > 0 57 | ); 58 | const openIssues = open.filter( 59 | (item) => item.githubItems.filter((i) => i.type === "issue").length > 0 60 | ); 61 | 62 | const closed = items.filter( 63 | (item) => 64 | item.status === ActionStatus.closed && 65 | dayjs(item.resolvedAt).isAfter(dayjs().subtract(6, "days")) 66 | ); 67 | const closedMessages = closed.filter((item) => item.slackMessages.length > 0); 68 | const closedPRs = closed.filter( 69 | (item) => item.githubItems.filter((i) => i.type === "pull_request").length > 0 70 | ); 71 | const closedIssues = closed.filter( 72 | (item) => item.githubItems.filter((i) => i.type === "issue").length > 0 73 | ); 74 | 75 | const assigned = open.filter((item) => item.assigneeId !== null); 76 | const contributors = Array.from( 77 | new Set( 78 | assigned.map( 79 | (item) => 80 | MAINTAINERS.find( 81 | (m) => 82 | m.slack === item.assignee?.slackId || m.github === item.assignee?.githubUsername 83 | )?.id || 84 | item.assignee?.githubUsername || 85 | item.assignee?.slackId || 86 | item.assignee?.email || 87 | "" 88 | ) 89 | ) 90 | ); 91 | 92 | text += `\n\nProject: *${file.replace(".yml", "")}*`; 93 | text += `\nOpen action items: ${open.length} (${openMessages.length} slack messages, ${openPRs.length} pull requests, ${openIssues.length} issues)`; 94 | text += `\nTriaged this week: ${closed.length} (${closedMessages.length} slack messages, ${closedPRs.length} pull requests, ${closedIssues.length} issues)`; 95 | text += `\nTotal contributors: ${contributors.length} ${ 96 | contributors.length > 0 ? `(${contributors.join(", ")})` : "" 97 | }`; 98 | } 99 | 100 | text += `\n\nYou can opt out of these daily status reports by running \`/slacker opt-out\`.`; 101 | await slack.client.chat.postMessage({ channel: maintainer.slack, text }); 102 | } 103 | } catch (err) { 104 | console.log("🚨🚨 Error in status report cron job 🚨🚨"); 105 | console.error(err); 106 | } 107 | }; 108 | -------------------------------------------------------------------------------- /actions/assign.ts: -------------------------------------------------------------------------------- 1 | import { Block, KnownBlock } from "@slack/bolt"; 2 | import { ActionHandler } from "."; 3 | import { slack } from ".."; 4 | import prisma from "../lib/db"; 5 | import { indexDocument } from "../lib/elastic"; 6 | import metrics from "../lib/metrics"; 7 | import { logActivity, MAINTAINERS } from "../lib/utils"; 8 | 9 | export const assign: ActionHandler = async ({ ack, body, client, logger }) => { 10 | await ack(); 11 | 12 | try { 13 | const { user, channel, actions, message } = body as any; 14 | const [actionId, assigneeId] = actions[0].selected_option.value.split("-"); 15 | 16 | if (assigneeId === "unassigned") { 17 | await prisma.actionItem.update({ 18 | where: { id: actionId }, 19 | data: { assigneeId: null, assignedOn: null }, 20 | }); 21 | 22 | await client.chat.postEphemeral({ 23 | channel: channel?.id as string, 24 | user: user.id, 25 | text: `:white_check_mark: Action item (id=${actionId}) unassigned.`, 26 | }); 27 | 28 | await removeResolveButton(channel?.id as string, message.ts, actionId); 29 | await indexDocument(actionId); 30 | await logActivity(client, user.id, actionId, "unassigned"); 31 | metrics.increment("slack.unassigned", 1); 32 | return; 33 | } 34 | 35 | const maintainer = MAINTAINERS.find((m) => m.id === assigneeId); 36 | let userOnDb = await prisma.user.findFirst({ 37 | where: { 38 | OR: [ 39 | { slackId: maintainer?.slack }, 40 | { githubUsername: maintainer?.github }, 41 | { id: assigneeId }, 42 | ], 43 | }, 44 | }); 45 | 46 | if (!userOnDb && maintainer) { 47 | const userInfo = await client.users.info({ user: maintainer.slack as string }); 48 | userOnDb = await prisma.user.create({ 49 | data: { 50 | slackId: maintainer.slack, 51 | githubUsername: maintainer.github, 52 | email: userInfo.user?.profile?.email, 53 | }, 54 | }); 55 | } 56 | 57 | if (!userOnDb) return; 58 | 59 | await prisma.actionItem.update({ 60 | where: { id: actionId }, 61 | data: { assigneeId: userOnDb.id, assignedOn: new Date() }, 62 | }); 63 | 64 | await client.chat.postEphemeral({ 65 | channel: channel?.id as string, 66 | user: user.id, 67 | text: `:white_check_mark: Action item (id=${actionId}) assigned to <@${maintainer?.slack}>`, 68 | }); 69 | 70 | if (user.id !== maintainer?.slack) { 71 | await removeResolveButton(channel?.id as string, message.ts, actionId); 72 | } else { 73 | await addResolveButton(channel?.id as string, message.ts, actionId); 74 | } 75 | 76 | await indexDocument(actionId, { timesAssigned: 1 }); 77 | await logActivity(client, user.id, actionId, "assigned", maintainer?.slack); 78 | metrics.increment("slack.assigned", 1); 79 | } catch (err) { 80 | metrics.increment("errors.slack.assigned", 1); 81 | logger.error(err); 82 | } 83 | }; 84 | 85 | const removeResolveButton = async (channelId: string, messageId: string, actionId: string) => { 86 | const { messages } = await slack.client.conversations.history({ 87 | channel: channelId, 88 | latest: messageId, 89 | limit: 1, 90 | inclusive: true, 91 | }); 92 | 93 | const blocks = messages?.[0].blocks || []; 94 | const idx = blocks.findIndex( 95 | (block) => block.type === "section" && block.text?.text?.includes(actionId) 96 | ); 97 | 98 | const hasResolveButton = blocks[idx]?.accessory?.action_id === "resolve"; 99 | if (!hasResolveButton) return; 100 | 101 | const newBlocks = blocks.map((block, i) => { 102 | if (i === idx) return { ...block, accessory: undefined }; 103 | return block; 104 | }) as (Block | KnownBlock)[]; 105 | 106 | await slack.client.chat.update({ 107 | ts: messageId, 108 | channel: channelId, 109 | text: `Message updated: ${messageId}`, 110 | blocks: newBlocks, 111 | }); 112 | }; 113 | 114 | const addResolveButton = async (channelId: string, messageId: string, actionId: string) => { 115 | const { messages } = await slack.client.conversations.history({ 116 | channel: channelId, 117 | latest: messageId, 118 | limit: 1, 119 | inclusive: true, 120 | }); 121 | 122 | const blocks = messages?.[0].blocks || []; 123 | const idx = blocks.findIndex( 124 | (block) => block.type === "section" && block.text?.text?.includes(actionId) 125 | ); 126 | 127 | const hasAccessories = !!blocks[idx]?.accessory; 128 | if (hasAccessories) return; 129 | 130 | const newBlocks = blocks.map((block, i) => { 131 | if (i === idx) { 132 | return { 133 | ...block, 134 | accessory: { 135 | type: "button", 136 | text: { type: "plain_text", emoji: true, text: "Resolve" }, 137 | style: "primary", 138 | value: actionId, 139 | action_id: "resolve", 140 | }, 141 | }; 142 | } 143 | 144 | return block; 145 | }) as (Block | KnownBlock)[]; 146 | 147 | await slack.client.chat.update({ 148 | ts: messageId, 149 | channel: channelId, 150 | text: `Message updated: ${messageId}`, 151 | blocks: newBlocks, 152 | }); 153 | }; 154 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | // Users that are either authors, contributors or participants in slack threads, issues or pull requests 11 | model User { 12 | id String @id @default(cuid()) 13 | githubUsername String? 14 | email String? 15 | githubToken String? 16 | slackId String? 17 | optOut Boolean @default(true) 18 | 19 | messagesAsked SlackMessage[] 20 | issuesOpened GithubItem[] 21 | participatingItems Participant[] 22 | snoozedItems ActionItem[] 23 | assignedItems ActionItem[] @relation("assignee") 24 | volunteer VolunteerDetail[] 25 | 26 | createdAt DateTime @default(now()) 27 | updatedAt DateTime @updatedAt 28 | 29 | @@index([id]) 30 | } 31 | 32 | model SlackMessage { 33 | id String @id @default(cuid()) 34 | text String 35 | ts String // Cannot convert into DateTime since slack uses this as an ID (string) - 17241837419.1200 36 | replies Int @default(0) 37 | 38 | channelId String 39 | channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade) 40 | 41 | authorId String 42 | author User @relation(fields: [authorId], references: [id], onDelete: Cascade) 43 | 44 | actionItemId String 45 | actionItem ActionItem @relation(fields: [actionItemId], references: [id]) 46 | 47 | createdAt DateTime @default(now()) 48 | updatedAt DateTime @updatedAt 49 | 50 | @@index([id]) 51 | @@index([authorId]) 52 | @@index([channelId]) 53 | } 54 | 55 | model GithubItem { 56 | id String @id @default(cuid()) 57 | title String @default("") 58 | body String @default("") 59 | number Int 60 | nodeId String @unique 61 | state GithubState 62 | type GithubItemType 63 | 64 | actionItemId String 65 | actionItem ActionItem @relation(fields: [actionItemId], references: [id]) 66 | 67 | repositoryId String 68 | repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) 69 | 70 | authorId String 71 | author User @relation(fields: [authorId], references: [id], onDelete: Cascade) 72 | 73 | labelsOnItems LabelsOnItems[] 74 | volunteer VolunteerDetail? 75 | 76 | lastAssignedOn DateTime? 77 | lastPromptedOn DateTime? 78 | 79 | createdAt DateTime @default(now()) 80 | updatedAt DateTime @updatedAt 81 | 82 | @@index([id]) 83 | @@index([repositoryId]) 84 | @@index([authorId]) 85 | } 86 | 87 | // It can either be a slack message or a github issue / pull request 88 | model ActionItem { 89 | id String @id @default(cuid()) 90 | 91 | slackMessages SlackMessage[] 92 | githubItems GithubItem[] 93 | 94 | firstReplyOn DateTime? 95 | lastReplyOn DateTime? 96 | totalReplies Int 97 | participants Participant[] 98 | 99 | snoozeCount Int @default(0) 100 | snoozedUntil DateTime? 101 | snoozedBy User? @relation(fields: [snoozedById], references: [id]) 102 | snoozedById String? 103 | 104 | assignee User? @relation(fields: [assigneeId], references: [id], "assignee") 105 | assigneeId String? 106 | assignedOn DateTime? 107 | 108 | notes String @default("") 109 | reason String @default("") 110 | status ActionStatus 111 | flag ExtraFlags? 112 | resolvedAt DateTime? 113 | 114 | createdAt DateTime @default(now()) 115 | updatedAt DateTime @updatedAt 116 | 117 | followUps FollowUp[] @relation("parent") 118 | parentItems FollowUp[] @relation("followUp") 119 | 120 | @@index([id]) 121 | @@index([snoozedById]) 122 | } 123 | 124 | model FollowUp { 125 | parent ActionItem @relation("parent", fields: [parentId], references: [id], onDelete: Cascade) 126 | parentId String @map("parentId") 127 | 128 | nextItem ActionItem @relation("followUp", fields: [nextItemId], references: [id], onDelete: Cascade) 129 | nextItemId String @map("nextItemId") 130 | 131 | date DateTime 132 | createdAt DateTime @default(now()) 133 | updatedAt DateTime @updatedAt 134 | 135 | @@id([parentId, nextItemId]) 136 | @@index([parentId]) 137 | @@index([nextItemId]) 138 | } 139 | 140 | model VolunteerDetail { 141 | id String @id @default(cuid()) 142 | 143 | assignee User @relation(fields: [assigneeId], references: [id], onDelete: Cascade) 144 | assigneeId String 145 | assignedOn DateTime 146 | 147 | issue GithubItem? @relation(fields: [issueId], references: [id], onDelete: Cascade) 148 | issueId String @unique 149 | 150 | createdAt DateTime @default(now()) 151 | updatedAt DateTime @updatedAt 152 | 153 | @@index([id]) 154 | @@index([assigneeId]) 155 | } 156 | 157 | // Slack channels that are being monitored 158 | model Channel { 159 | id String @id @default(cuid()) 160 | name String 161 | slackId String @unique 162 | messages SlackMessage[] 163 | 164 | @@index([id]) 165 | } 166 | 167 | // List of users that are involved in the thread or an issue / pull request 168 | model Participant { 169 | userId String 170 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 171 | 172 | actionItemId String 173 | actionItem ActionItem @relation(fields: [actionItemId], references: [id], onDelete: Cascade) 174 | 175 | @@id([userId, actionItemId]) 176 | } 177 | 178 | model LabelsOnItems { 179 | labelId String 180 | label Label @relation(fields: [labelId], references: [id], onDelete: Cascade) 181 | 182 | itemId String 183 | item GithubItem @relation(fields: [itemId], references: [id], onDelete: Cascade) 184 | 185 | @@id([labelId, itemId]) 186 | } 187 | 188 | model Label { 189 | id String @id @default(cuid()) 190 | name String @unique 191 | labelsOnItems LabelsOnItems[] 192 | 193 | @@index([id]) 194 | } 195 | 196 | model Repository { 197 | id String @id @default(cuid()) 198 | name String 199 | owner String 200 | url String @unique 201 | 202 | items GithubItem[] 203 | 204 | @@index([id]) 205 | } 206 | 207 | enum GithubItemType { 208 | issue 209 | pull_request 210 | } 211 | 212 | enum GithubState { 213 | open 214 | closed 215 | } 216 | 217 | enum ActionStatus { 218 | open 219 | closed 220 | followUp 221 | } 222 | 223 | enum ExtraFlags { 224 | irrelevant 225 | resolved 226 | } 227 | -------------------------------------------------------------------------------- /events/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ActionItem, SlackMessage, User } from "@prisma/client"; 2 | import { KnownEventFromType } from "@slack/bolt"; 3 | import type { Message } from "@slack/web-api/dist/response/ConversationsHistoryResponse"; 4 | import dayjs from "dayjs"; 5 | import { slack } from ".."; 6 | import prisma from "../lib/db"; 7 | import { indexDocument } from "../lib/elastic"; 8 | import { getMaintainers, syncParticipants } from "../lib/utils"; 9 | 10 | export interface ParentMessage extends SlackMessage { 11 | actionItem: ActionItem & { 12 | participants: { user: User }[]; 13 | slackMessages: { replies: number }[]; 14 | }; 15 | } 16 | 17 | const ALLOWED_BOTS = ["B03QGF0H9FU", "B03701P4QN8", "B05SHCXE1UY", "B02F604SCGP", "B07FX3F8BFD", "B07HM2ZHJTA"]; 18 | 19 | export const checkMessageConditions = async (message: KnownEventFromType<"message">) => { 20 | const invalid = { channel: null, parent: null }; 21 | 22 | if (message.subtype === "message_deleted") { 23 | await prisma.slackMessage.deleteMany({ 24 | where: { ts: message.deleted_ts, channel: { slackId: message.channel } }, 25 | }); 26 | 27 | return invalid; 28 | } 29 | 30 | if (message.subtype || (message.bot_id && !ALLOWED_BOTS.includes(message.bot_id))) return invalid; 31 | if ((message.text?.length || 0) <= 4) return invalid; 32 | if (message.text?.startsWith(":") && message.text?.endsWith(":") && !message.text.includes(" ")) 33 | return invalid; 34 | 35 | const channel = await prisma.channel.findFirst({ where: { slackId: message.channel } }); 36 | if (!channel) return invalid; 37 | 38 | const parent = await slack.client.conversations 39 | .history({ channel: message.channel, latest: message.thread_ts, limit: 1, inclusive: true }) 40 | .then((res) => res.messages?.[0]); 41 | 42 | if (!parent) return invalid; 43 | 44 | return { channel, parent }; 45 | }; 46 | 47 | export const updateExistingMessage = async ( 48 | parentInDb: ParentMessage, 49 | parent: Message, 50 | threadReplies: Message[] 51 | ) => { 52 | const firstReplyOn = 53 | threadReplies?.[0]?.ts && parentInDb.actionItem.firstReplyOn 54 | ? dayjs.min([ 55 | dayjs(threadReplies[0].ts.split(".")[0], "X"), 56 | dayjs(parentInDb.actionItem.firstReplyOn), 57 | ]) 58 | : parentInDb.actionItem.firstReplyOn 59 | ? dayjs(parentInDb.actionItem.firstReplyOn) 60 | : threadReplies?.[0]?.ts 61 | ? dayjs(threadReplies[0].ts.split(".")[0], "X") 62 | : undefined; 63 | 64 | const lastReplyOn = 65 | parent.latest_reply && parentInDb.actionItem.lastReplyOn 66 | ? dayjs.max([ 67 | dayjs(parent.latest_reply.split(".")[0], "X"), 68 | dayjs(parentInDb.actionItem.lastReplyOn), 69 | ]) 70 | : parent.latest_reply && !parentInDb.actionItem.lastReplyOn 71 | ? dayjs(parent.latest_reply.split(".")[0], "X") 72 | : parentInDb.actionItem.lastReplyOn && !parent.latest_reply 73 | ? dayjs(parentInDb.actionItem.lastReplyOn) 74 | : undefined; 75 | 76 | await prisma.actionItem.update({ 77 | where: { id: parentInDb.actionItem.id }, 78 | data: { 79 | firstReplyOn: firstReplyOn?.toDate(), 80 | lastReplyOn: lastReplyOn?.toDate(), 81 | totalReplies: parentInDb.actionItem.slackMessages.reduce((acc, cur) => acc + cur.replies, 0), 82 | participants: { deleteMany: {} }, 83 | slackMessages: { 84 | update: { 85 | where: { id: parentInDb.id }, 86 | data: { text: parent.text || "", replies: parent.reply_count }, 87 | }, 88 | }, 89 | }, 90 | }); 91 | 92 | const participants = Array.from( 93 | new Set( 94 | parent.reply_users?.concat( 95 | parentInDb.actionItem.participants.map((p) => p.user.slackId).filter((p) => p) as string[] 96 | ) || [] 97 | ) 98 | ); 99 | 100 | await syncParticipants(participants, parentInDb.actionItem.id); 101 | await indexDocument(parentInDb.actionItem.id); 102 | }; 103 | 104 | interface CreateMessageProps { 105 | recentSlackMessage: (SlackMessage & { actionItem: { id: string } }) | null; 106 | parent: Message; 107 | threadReplies: Message[]; 108 | message: KnownEventFromType<"message">; 109 | author: User; 110 | } 111 | 112 | export const createGroupedMessage = async (props: CreateMessageProps) => { 113 | const { recentSlackMessage, parent, threadReplies, message, author } = props; 114 | 115 | const slackMessage = await prisma.slackMessage.create({ 116 | data: { 117 | text: parent.text || "", 118 | ts: parent.ts || "", 119 | replies: parent.reply_count, 120 | createdAt: dayjs(parent.ts?.split(".")[0], "X").toDate(), 121 | actionItem: { connect: { id: recentSlackMessage?.actionItem.id } }, 122 | channel: { connect: { slackId: message.channel } }, 123 | author: { connect: { id: author.id } }, 124 | }, 125 | include: { 126 | actionItem: { 127 | include: { 128 | participants: { select: { user: true } }, 129 | slackMessages: { select: { replies: true } }, 130 | }, 131 | }, 132 | }, 133 | }); 134 | 135 | const firstReplyOn = 136 | threadReplies?.[0]?.ts && slackMessage.actionItem.firstReplyOn 137 | ? dayjs.min([ 138 | dayjs(threadReplies[0].ts.split(".")[0], "X"), 139 | dayjs(slackMessage.actionItem.firstReplyOn), 140 | ]) 141 | : slackMessage.actionItem.firstReplyOn 142 | ? dayjs(slackMessage.actionItem.firstReplyOn) 143 | : threadReplies?.[0]?.ts 144 | ? dayjs(threadReplies[0].ts.split(".")[0], "X") 145 | : undefined; 146 | 147 | const lastReplyOn = 148 | parent.latest_reply && slackMessage.actionItem.lastReplyOn 149 | ? dayjs.max([ 150 | dayjs(parent.latest_reply.split(".")[0], "X"), 151 | dayjs(slackMessage.actionItem.lastReplyOn), 152 | ]) 153 | : parent.latest_reply && !slackMessage.actionItem.lastReplyOn 154 | ? dayjs(parent.latest_reply.split(".")[0], "X") 155 | : slackMessage.actionItem.lastReplyOn && !parent.latest_reply 156 | ? dayjs(slackMessage.actionItem.lastReplyOn) 157 | : undefined; 158 | 159 | await prisma.actionItem.update({ 160 | where: { id: recentSlackMessage?.actionItem.id }, 161 | data: { 162 | firstReplyOn: firstReplyOn?.toDate(), 163 | lastReplyOn: lastReplyOn?.toDate(), 164 | totalReplies: slackMessage.actionItem.slackMessages.reduce( 165 | (acc, cur) => acc + cur.replies, 166 | 0 167 | ), 168 | }, 169 | }); 170 | 171 | return slackMessage; 172 | }; 173 | 174 | export const createNewMessage = async (props: CreateMessageProps) => { 175 | const { parent, threadReplies, message, author } = props; 176 | 177 | const maintainers = getMaintainers({ channelId: message.channel }); 178 | if (maintainers.find((maintainer) => maintainer.slack === parent.user)) return; 179 | 180 | return await prisma.slackMessage.create({ 181 | data: { 182 | text: parent.text || "", 183 | ts: parent.ts || "", 184 | replies: parent.reply_count, 185 | createdAt: dayjs(parent.ts?.split(".")[0], "X").toDate(), 186 | actionItem: { 187 | create: { 188 | lastReplyOn: parent.latest_reply 189 | ? dayjs(parent.latest_reply.split(".")[0], "X").toDate() 190 | : undefined, 191 | firstReplyOn: threadReplies?.[0]?.ts 192 | ? dayjs(threadReplies[0].ts.split(".")[0], "X").toDate() 193 | : undefined, 194 | totalReplies: parent.reply_count || 0, 195 | status: "open", 196 | }, 197 | }, 198 | channel: { connect: { slackId: message.channel } }, 199 | author: { connect: { id: author.id } }, 200 | }, 201 | include: { 202 | actionItem: { 203 | include: { 204 | participants: { select: { user: true } }, 205 | slackMessages: { select: { replies: true } }, 206 | }, 207 | }, 208 | }, 209 | }); 210 | }; 211 | -------------------------------------------------------------------------------- /lib/elastic.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@elastic/elasticsearch"; 2 | import { ActionItem, ActionStatus, User } from "@prisma/client"; 3 | import dayjs from "dayjs"; 4 | import { config } from "dotenv"; 5 | import prisma from "./db"; 6 | import { getDisplayName } from "./octokit"; 7 | import { ElasticDocument, ItemType, State } from "./types"; 8 | import { MAINTAINERS, getProjectName } from "./utils"; 9 | import metrics from "./metrics"; 10 | config(); 11 | 12 | export const elastic = new Client({ 13 | node: process.env.ELASTIC_NODE || "https://localhost:9200", 14 | auth: { apiKey: process.env.ELASTIC_API_TOKEN || "" }, 15 | tls: { rejectUnauthorized: false }, 16 | }); 17 | 18 | const INDEX_NAME = "search-slacker-analytics"; 19 | 20 | const getParticipant = async ( 21 | user: User, 22 | item: ActionItem & { 23 | slackMessages: { channel: { slackId: string }; author: { slackId: string | null } }[]; 24 | githubItems: { 25 | repository: { owner: string; name: string }; 26 | author: { githubUsername: string | null }; 27 | }[]; 28 | } 29 | ) => { 30 | const maintainer = MAINTAINERS.find( 31 | (maintainer) => maintainer.github === user.githubUsername || maintainer.slack === user.slackId 32 | ); 33 | 34 | if (maintainer) { 35 | return { 36 | displayName: maintainer.id, 37 | github: maintainer.github, 38 | slack: maintainer.slack, 39 | }; 40 | } else { 41 | const displayName = await getDisplayName({ 42 | owner: item.githubItems[0]?.repository.owner ?? "", 43 | name: item.githubItems[0]?.repository.name ?? "", 44 | github: user.githubUsername ?? undefined, 45 | slackId: user.slackId ?? undefined, 46 | }); 47 | 48 | return { displayName, github: user.githubUsername, slack: user.slackId }; 49 | } 50 | }; 51 | 52 | const getActor = async ( 53 | actor: User, 54 | item: ActionItem & { 55 | slackMessages: { channel: { slackId: string }; author: { slackId: string | null } }[]; 56 | githubItems: { 57 | repository: { owner: string; name: string }; 58 | author: { githubUsername: string | null }; 59 | }[]; 60 | } 61 | ) => { 62 | const displayName = await getDisplayName({ 63 | owner: item.githubItems[0]?.repository.owner ?? "", 64 | name: item.githubItems[0]?.repository.name ?? "", 65 | github: actor.githubUsername ?? undefined, 66 | slackId: actor.slackId ?? undefined, 67 | }); 68 | 69 | return { 70 | displayName, 71 | github: actor.githubUsername, 72 | slack: actor.slackId, 73 | }; 74 | }; 75 | 76 | export const indexDocument = async (id: string, data?: ElasticDocument) => { 77 | try { 78 | const item = await prisma.actionItem.findUnique({ 79 | where: { id }, 80 | include: { 81 | slackMessages: { include: { channel: true, author: true } }, 82 | githubItems: { include: { repository: true, author: true } }, 83 | parentItems: { 84 | include: { 85 | parent: { 86 | include: { 87 | slackMessages: { include: { channel: true, author: true } }, 88 | githubItems: { include: { repository: true, author: true } }, 89 | }, 90 | }, 91 | }, 92 | orderBy: { date: "desc" }, 93 | take: 1, 94 | }, 95 | assignee: true, 96 | snoozedBy: true, 97 | participants: { select: { user: true } }, 98 | }, 99 | }); 100 | 101 | if (!item) return; 102 | 103 | const doc = await elastic 104 | .get({ id: item.id, index: INDEX_NAME }) 105 | .then((res) => res) 106 | .catch(() => undefined); 107 | 108 | let participants: ElasticDocument["actors"] = (doc?._source?.actors ?? []).filter( 109 | (p) => 110 | item.participants.findIndex( 111 | ({ user }) => p.github === user.githubUsername || p.slack === user.slackId 112 | ) !== -1 113 | ); 114 | 115 | participants = await Promise.all( 116 | item.participants.map(({ user }) => getParticipant(user, item)) 117 | ); 118 | 119 | if (item.snoozedBy) { 120 | const snoozedBy = participants.find( 121 | (actor) => 122 | actor.slack === item.snoozedBy?.slackId || actor.github === item.snoozedBy?.githubUsername 123 | ); 124 | 125 | if (!snoozedBy) { 126 | participants.push(await getActor(item.snoozedBy, item)); 127 | } 128 | } 129 | 130 | if (item.assignee) { 131 | const assignee = participants.find( 132 | (actor) => 133 | actor.slack === item.assignee?.slackId || actor.github === item.assignee?.githubUsername 134 | ); 135 | 136 | if (!assignee) { 137 | participants.push(await getActor(item.assignee, item)); 138 | } 139 | } 140 | 141 | let timesAssigned = (doc?._source?.timesAssigned ?? 0) + (data?.timesAssigned ?? 0); 142 | timesAssigned = timesAssigned === 0 && item.assignee ? 1 : timesAssigned; 143 | 144 | const isFollowUp = item.parentItems.length > 0; 145 | 146 | const createdAt = 147 | item.githubItems[0]?.createdAt || 148 | dayjs(item.slackMessages[0]?.ts?.split(".")[0], "X").toDate(); 149 | 150 | const project = getProjectName({ 151 | channelId: item.slackMessages[0]?.channel.slackId, 152 | repoUrl: item.githubItems[0]?.repository.url, 153 | }); 154 | 155 | if (!project) return; 156 | if (isFollowUp) return; 157 | 158 | await elastic.index({ 159 | id: item.id, 160 | timeout: "1m", 161 | index: "search-slacker-analytics", 162 | document: { 163 | id: item.id, 164 | actionItemType: 165 | item.slackMessages.length > 0 166 | ? ItemType.message 167 | : item.githubItems[0].type === "issue" 168 | ? ItemType.issue 169 | : ItemType.pull, 170 | createdTime: createdAt ?? item.createdAt, 171 | resolvedTime: item.resolvedAt, 172 | firstResponseTime: item.firstReplyOn, 173 | reason: item.reason, 174 | state: 175 | item.snoozedUntil && dayjs(item.snoozedUntil).isAfter(dayjs()) 176 | ? State.snoozed 177 | : item.status === ActionStatus.closed 178 | ? item.slackMessages.length > 0 179 | ? State.resolved 180 | : State.triaged 181 | : State.open, 182 | lastModifiedTime: 183 | item.lastReplyOn ?? 184 | item.slackMessages.at(-1)?.updatedAt ?? 185 | item.githubItems[0].updatedAt ?? 186 | item.updatedAt, 187 | project, 188 | source: 189 | item.githubItems.length > 0 190 | ? item.githubItems[0].repository.owner + "/" + item.githubItems[0].repository.name 191 | : `#${item.slackMessages[0].channel.name}`, 192 | snoozedUntil: item.snoozedUntil, 193 | timesCommented: item.totalReplies, 194 | timesReopened: (doc?._source?.timesReopened ?? 0) + (data?.timesReopened ?? 0), 195 | timesResolved: (doc?._source?.timesResolved ?? 0) + (data?.timesResolved ?? 0), 196 | timesAssigned, 197 | timesSnoozed: item.snoozeCount, 198 | firstResponseTimeInS: item.firstReplyOn 199 | ? dayjs(item.firstReplyOn).diff(createdAt, "seconds") 200 | : null, 201 | resolutionTimeInS: item.resolvedAt 202 | ? dayjs(item.resolvedAt).diff(createdAt, "seconds") 203 | : null, 204 | actors: participants, 205 | assignee: participants.find( 206 | (actor) => 207 | actor.slack === item.assignee?.slackId || actor.github === item.assignee?.githubUsername 208 | ), 209 | author: participants.find( 210 | (actor) => 211 | actor.slack === 212 | (item.slackMessages.length > 0 ? item.slackMessages : item.githubItems)[0].author 213 | .slackId || 214 | actor.github === 215 | (item.slackMessages.length > 0 ? item.slackMessages : item.githubItems)[0].author 216 | .githubUsername 217 | ), 218 | url: 219 | item.githubItems.length > 0 220 | ? `https://github.com/${item.githubItems[0].repository?.owner}/${item.githubItems[0].repository?.name}/issues/${item.githubItems[0].number}` 221 | : `https://hackclub.slack.com/archives/${ 222 | item.slackMessages[0].channel.slackId 223 | }/p${item.slackMessages[0]?.ts.replace(".", "")}`, 224 | }, 225 | }); 226 | } catch (err) { 227 | metrics.increment("errors.elastic.index", 1); 228 | console.error(err); 229 | } 230 | }; 231 | -------------------------------------------------------------------------------- /prisma/migrations/20240109175925_restructure/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "GithubItemType" AS ENUM ('issue', 'pull_request'); 3 | 4 | -- CreateEnum 5 | CREATE TYPE "GithubState" AS ENUM ('open', 'closed'); 6 | 7 | -- CreateEnum 8 | CREATE TYPE "ActionStatus" AS ENUM ('open', 'closed'); 9 | 10 | -- CreateEnum 11 | CREATE TYPE "ExtraFlags" AS ENUM ('irrelevant', 'resolved'); 12 | 13 | -- CreateTable 14 | CREATE TABLE "User" ( 15 | "id" TEXT NOT NULL, 16 | "githubUsername" TEXT, 17 | "email" TEXT, 18 | "githubToken" TEXT, 19 | "slackId" TEXT, 20 | "optOut" BOOLEAN NOT NULL DEFAULT true, 21 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | "updatedAt" TIMESTAMP(3) NOT NULL, 23 | 24 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 25 | ); 26 | 27 | -- CreateTable 28 | CREATE TABLE "SlackMessage" ( 29 | "id" TEXT NOT NULL, 30 | "text" TEXT NOT NULL, 31 | "ts" TEXT NOT NULL, 32 | "replies" INTEGER NOT NULL DEFAULT 0, 33 | "channelId" TEXT NOT NULL, 34 | "authorId" TEXT NOT NULL, 35 | "actionItemId" TEXT NOT NULL, 36 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 37 | "updatedAt" TIMESTAMP(3) NOT NULL, 38 | 39 | CONSTRAINT "SlackMessage_pkey" PRIMARY KEY ("id") 40 | ); 41 | 42 | -- CreateTable 43 | CREATE TABLE "GithubItem" ( 44 | "id" TEXT NOT NULL, 45 | "title" TEXT NOT NULL DEFAULT '', 46 | "body" TEXT NOT NULL DEFAULT '', 47 | "number" INTEGER NOT NULL, 48 | "nodeId" TEXT NOT NULL, 49 | "state" "GithubState" NOT NULL, 50 | "type" "GithubItemType" NOT NULL, 51 | "actionItemId" TEXT NOT NULL, 52 | "repositoryId" TEXT NOT NULL, 53 | "authorId" TEXT NOT NULL, 54 | "lastAssignedOn" TIMESTAMP(3), 55 | "lastPromptedOn" TIMESTAMP(3), 56 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 57 | "updatedAt" TIMESTAMP(3) NOT NULL, 58 | 59 | CONSTRAINT "GithubItem_pkey" PRIMARY KEY ("id") 60 | ); 61 | 62 | -- CreateTable 63 | CREATE TABLE "ActionItem" ( 64 | "id" TEXT NOT NULL, 65 | "firstReplyOn" TIMESTAMP(3), 66 | "lastReplyOn" TIMESTAMP(3), 67 | "totalReplies" INTEGER NOT NULL, 68 | "snoozeCount" INTEGER NOT NULL DEFAULT 0, 69 | "snoozedUntil" TIMESTAMP(3), 70 | "snoozedById" TEXT, 71 | "assigneeId" TEXT, 72 | "assignedOn" TIMESTAMP(3), 73 | "notes" TEXT NOT NULL DEFAULT '', 74 | "reason" TEXT NOT NULL DEFAULT '', 75 | "status" "ActionStatus" NOT NULL, 76 | "flag" "ExtraFlags", 77 | "resolvedAt" TIMESTAMP(3), 78 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 79 | "updatedAt" TIMESTAMP(3) NOT NULL, 80 | 81 | CONSTRAINT "ActionItem_pkey" PRIMARY KEY ("id") 82 | ); 83 | 84 | -- CreateTable 85 | CREATE TABLE "FollowUp" ( 86 | "actionItemId" TEXT NOT NULL, 87 | "userId" TEXT NOT NULL, 88 | "date" TIMESTAMP(3) NOT NULL, 89 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 90 | "updatedAt" TIMESTAMP(3) NOT NULL, 91 | 92 | CONSTRAINT "FollowUp_pkey" PRIMARY KEY ("actionItemId","userId") 93 | ); 94 | 95 | -- CreateTable 96 | CREATE TABLE "VolunteerDetail" ( 97 | "id" TEXT NOT NULL, 98 | "assigneeId" TEXT NOT NULL, 99 | "assignedOn" TIMESTAMP(3) NOT NULL, 100 | "issueId" TEXT NOT NULL, 101 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 102 | "updatedAt" TIMESTAMP(3) NOT NULL, 103 | 104 | CONSTRAINT "VolunteerDetail_pkey" PRIMARY KEY ("id") 105 | ); 106 | 107 | -- CreateTable 108 | CREATE TABLE "Channel" ( 109 | "id" TEXT NOT NULL, 110 | "name" TEXT NOT NULL, 111 | "slackId" TEXT NOT NULL, 112 | 113 | CONSTRAINT "Channel_pkey" PRIMARY KEY ("id") 114 | ); 115 | 116 | -- CreateTable 117 | CREATE TABLE "Participant" ( 118 | "userId" TEXT NOT NULL, 119 | "actionItemId" TEXT NOT NULL, 120 | 121 | CONSTRAINT "Participant_pkey" PRIMARY KEY ("userId","actionItemId") 122 | ); 123 | 124 | -- CreateTable 125 | CREATE TABLE "LabelsOnItems" ( 126 | "labelId" TEXT NOT NULL, 127 | "itemId" TEXT NOT NULL, 128 | 129 | CONSTRAINT "LabelsOnItems_pkey" PRIMARY KEY ("labelId","itemId") 130 | ); 131 | 132 | -- CreateTable 133 | CREATE TABLE "Label" ( 134 | "id" TEXT NOT NULL, 135 | "name" TEXT NOT NULL, 136 | 137 | CONSTRAINT "Label_pkey" PRIMARY KEY ("id") 138 | ); 139 | 140 | -- CreateTable 141 | CREATE TABLE "Repository" ( 142 | "id" TEXT NOT NULL, 143 | "name" TEXT NOT NULL, 144 | "owner" TEXT NOT NULL, 145 | "url" TEXT NOT NULL, 146 | 147 | CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") 148 | ); 149 | 150 | -- CreateIndex 151 | CREATE INDEX "User_id_idx" ON "User"("id"); 152 | 153 | -- CreateIndex 154 | CREATE INDEX "SlackMessage_id_idx" ON "SlackMessage"("id"); 155 | 156 | -- CreateIndex 157 | CREATE INDEX "SlackMessage_authorId_idx" ON "SlackMessage"("authorId"); 158 | 159 | -- CreateIndex 160 | CREATE INDEX "SlackMessage_channelId_idx" ON "SlackMessage"("channelId"); 161 | 162 | -- CreateIndex 163 | CREATE UNIQUE INDEX "GithubItem_nodeId_key" ON "GithubItem"("nodeId"); 164 | 165 | -- CreateIndex 166 | CREATE INDEX "GithubItem_id_idx" ON "GithubItem"("id"); 167 | 168 | -- CreateIndex 169 | CREATE INDEX "GithubItem_repositoryId_idx" ON "GithubItem"("repositoryId"); 170 | 171 | -- CreateIndex 172 | CREATE INDEX "GithubItem_authorId_idx" ON "GithubItem"("authorId"); 173 | 174 | -- CreateIndex 175 | CREATE INDEX "ActionItem_id_idx" ON "ActionItem"("id"); 176 | 177 | -- CreateIndex 178 | CREATE INDEX "ActionItem_snoozedById_idx" ON "ActionItem"("snoozedById"); 179 | 180 | -- CreateIndex 181 | CREATE INDEX "FollowUp_actionItemId_idx" ON "FollowUp"("actionItemId"); 182 | 183 | -- CreateIndex 184 | CREATE INDEX "FollowUp_userId_idx" ON "FollowUp"("userId"); 185 | 186 | -- CreateIndex 187 | CREATE UNIQUE INDEX "VolunteerDetail_issueId_key" ON "VolunteerDetail"("issueId"); 188 | 189 | -- CreateIndex 190 | CREATE INDEX "VolunteerDetail_id_idx" ON "VolunteerDetail"("id"); 191 | 192 | -- CreateIndex 193 | CREATE INDEX "VolunteerDetail_assigneeId_idx" ON "VolunteerDetail"("assigneeId"); 194 | 195 | -- CreateIndex 196 | CREATE UNIQUE INDEX "Channel_slackId_key" ON "Channel"("slackId"); 197 | 198 | -- CreateIndex 199 | CREATE INDEX "Channel_id_idx" ON "Channel"("id"); 200 | 201 | -- CreateIndex 202 | CREATE UNIQUE INDEX "Label_name_key" ON "Label"("name"); 203 | 204 | -- CreateIndex 205 | CREATE INDEX "Label_id_idx" ON "Label"("id"); 206 | 207 | -- CreateIndex 208 | CREATE UNIQUE INDEX "Repository_url_key" ON "Repository"("url"); 209 | 210 | -- CreateIndex 211 | CREATE INDEX "Repository_id_idx" ON "Repository"("id"); 212 | 213 | -- AddForeignKey 214 | ALTER TABLE "SlackMessage" ADD CONSTRAINT "SlackMessage_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE; 215 | 216 | -- AddForeignKey 217 | ALTER TABLE "SlackMessage" ADD CONSTRAINT "SlackMessage_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 218 | 219 | -- AddForeignKey 220 | ALTER TABLE "SlackMessage" ADD CONSTRAINT "SlackMessage_actionItemId_fkey" FOREIGN KEY ("actionItemId") REFERENCES "ActionItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 221 | 222 | -- AddForeignKey 223 | ALTER TABLE "GithubItem" ADD CONSTRAINT "GithubItem_actionItemId_fkey" FOREIGN KEY ("actionItemId") REFERENCES "ActionItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 224 | 225 | -- AddForeignKey 226 | ALTER TABLE "GithubItem" ADD CONSTRAINT "GithubItem_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; 227 | 228 | -- AddForeignKey 229 | ALTER TABLE "GithubItem" ADD CONSTRAINT "GithubItem_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 230 | 231 | -- AddForeignKey 232 | ALTER TABLE "ActionItem" ADD CONSTRAINT "ActionItem_snoozedById_fkey" FOREIGN KEY ("snoozedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; 233 | 234 | -- AddForeignKey 235 | ALTER TABLE "ActionItem" ADD CONSTRAINT "ActionItem_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; 236 | 237 | -- AddForeignKey 238 | ALTER TABLE "FollowUp" ADD CONSTRAINT "FollowUp_actionItemId_fkey" FOREIGN KEY ("actionItemId") REFERENCES "ActionItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; 239 | 240 | -- AddForeignKey 241 | ALTER TABLE "FollowUp" ADD CONSTRAINT "FollowUp_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 242 | 243 | -- AddForeignKey 244 | ALTER TABLE "VolunteerDetail" ADD CONSTRAINT "VolunteerDetail_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 245 | 246 | -- AddForeignKey 247 | ALTER TABLE "VolunteerDetail" ADD CONSTRAINT "VolunteerDetail_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "GithubItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; 248 | 249 | -- AddForeignKey 250 | ALTER TABLE "Participant" ADD CONSTRAINT "Participant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 251 | 252 | -- AddForeignKey 253 | ALTER TABLE "Participant" ADD CONSTRAINT "Participant_actionItemId_fkey" FOREIGN KEY ("actionItemId") REFERENCES "ActionItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; 254 | 255 | -- AddForeignKey 256 | ALTER TABLE "LabelsOnItems" ADD CONSTRAINT "LabelsOnItems_labelId_fkey" FOREIGN KEY ("labelId") REFERENCES "Label"("id") ON DELETE CASCADE ON UPDATE CASCADE; 257 | 258 | -- AddForeignKey 259 | ALTER TABLE "LabelsOnItems" ADD CONSTRAINT "LabelsOnItems_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "GithubItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; 260 | -------------------------------------------------------------------------------- /routes.ts: -------------------------------------------------------------------------------- 1 | import { ConnectRouter } from "@connectrpc/connect"; 2 | import { GithubItemType, GithubState, User } from "@prisma/client"; 3 | import { readdirSync } from "fs"; 4 | import { ElizaService } from "./gen/eliza_connect"; 5 | import prisma from "./lib/db"; 6 | import { indexDocument } from "./lib/elastic"; 7 | import { getGithubItem, listGithubItems } from "./lib/octokit"; 8 | import { checkNeedsNotifying, getYamlFile, syncGithubParticipants } from "./lib/utils"; 9 | 10 | export default (router: ConnectRouter) => 11 | router.service(ElizaService, { 12 | async syncGithubItems() { 13 | try { 14 | const files = readdirSync("./config"); 15 | 16 | // Use Promise.all to process all files in parallel 17 | await Promise.all( 18 | files.map(async (file) => { 19 | const { repos } = getYamlFile(file); 20 | const progress = `${files.indexOf(file) + 1} / ${files.length}`; 21 | console.log(`🐱🐱 ${progress} Syncing file: ${file} 🐱🐱`); 22 | 23 | // Use Promise.all to process all repos in parallel 24 | await Promise.all( 25 | repos.map(async (repo) => { 26 | const owner = repo.uri.split("/")[3]; 27 | const name = repo.uri.split("/")[4]; 28 | 29 | console.log(`===================== ${owner}/${name} =====================`); 30 | 31 | const dbRepo = await prisma.repository.upsert({ 32 | where: { url: repo.uri }, 33 | create: { name, owner, url: repo.uri }, 34 | update: { name, owner }, 35 | }); 36 | 37 | const items = await listGithubItems(owner, name); 38 | 39 | for await (const item of items) { 40 | // const maintainers = getMaintainers({ repoUrl: repo.uri }); 41 | // if (maintainers.find((maintainer) => maintainer?.github === item.author.login)) 42 | // continue; 43 | 44 | // find user by login 45 | const user = await prisma.user.findFirst({ 46 | where: { githubUsername: item.author.login }, 47 | }); 48 | let author: User; 49 | 50 | if (!user) 51 | author = await prisma.user.create({ 52 | data: { githubUsername: item.author.login }, 53 | }); 54 | else author = user; 55 | 56 | const actionItem = await prisma.actionItem.findFirst({ 57 | where: { githubItems: { some: { nodeId: item.id } } }, 58 | }); 59 | 60 | const githubItem = await prisma.githubItem.upsert({ 61 | where: { nodeId: item.id }, 62 | create: { 63 | author: { connect: { id: author.id } }, 64 | repository: { connect: { id: dbRepo.id } }, 65 | nodeId: item.id, 66 | title: item.title, 67 | body: item.bodyText, 68 | number: item.number, 69 | state: "open", 70 | type: item.id.startsWith("I_") 71 | ? GithubItemType.issue 72 | : GithubItemType.pull_request, 73 | createdAt: item.createdAt, 74 | updatedAt: item.updatedAt, 75 | lastAssignedOn: item.timelineItems.edges[0]?.node.createdAt, 76 | actionItem: { 77 | create: { 78 | status: "open", 79 | totalReplies: item.comments.totalCount ?? 0, 80 | firstReplyOn: item.comments.nodes[0]?.createdAt, 81 | lastReplyOn: 82 | item.comments.nodes[item.comments.nodes.length - 1]?.createdAt, 83 | }, 84 | }, 85 | labelsOnItems: { 86 | create: item.labels.nodes.map(({ name }) => ({ 87 | label: { connectOrCreate: { where: { name }, create: { name } } }, 88 | })), 89 | }, 90 | }, 91 | update: { 92 | state: "open", 93 | title: item.title, 94 | body: item.bodyText, 95 | updatedAt: item.updatedAt, 96 | labelsOnItems: { 97 | deleteMany: {}, 98 | create: item.labels.nodes.map(({ name }) => ({ 99 | label: { connectOrCreate: { where: { name }, create: { name } } }, 100 | })), 101 | }, 102 | actionItem: { 103 | update: { 104 | status: actionItem?.resolvedAt ? "closed" : "open", 105 | totalReplies: item.comments.totalCount, 106 | firstReplyOn: item.comments.nodes[0]?.createdAt, 107 | lastReplyOn: 108 | item.comments.nodes[item.comments.nodes.length - 1]?.createdAt, 109 | participants: { deleteMany: {} }, 110 | }, 111 | }, 112 | }, 113 | include: { actionItem: true }, 114 | }); 115 | 116 | const logins = item.participants.nodes.map((node) => node.login); 117 | await syncGithubParticipants(logins, githubItem.actionItem!.id); 118 | !actionItem && (await checkNeedsNotifying(githubItem.actionItem!.id)); 119 | indexDocument(githubItem.actionItem!.id); 120 | } 121 | 122 | console.log( 123 | `===================== Syncing closed items: ${owner}/${name} =====================` 124 | ); 125 | 126 | const dbItems = await prisma.githubItem.findMany({ 127 | where: { repositoryId: dbRepo.id, state: GithubState.open }, 128 | }); 129 | const ids = dbItems.map((item) => item.nodeId); 130 | const openIds = items.map((item) => item.id); 131 | const closedIds = ids.filter((id) => !openIds.includes(id)); 132 | 133 | // close the action items that are closed on github 134 | for await (const id of closedIds) { 135 | const res = await getGithubItem(owner, name, id); 136 | 137 | const githubItem = await prisma.githubItem.update({ 138 | where: { nodeId: id }, 139 | data: { 140 | state: "closed", 141 | labelsOnItems: { 142 | deleteMany: {}, 143 | create: res.node.labels.nodes.map(({ name }) => ({ 144 | label: { connectOrCreate: { where: { name }, create: { name } } }, 145 | })), 146 | }, 147 | lastAssignedOn: res.node.timelineItems.edges[0]?.node.createdAt, 148 | actionItem: { 149 | update: { 150 | status: "closed", 151 | totalReplies: res.node.comments.totalCount, 152 | firstReplyOn: res.node.comments.nodes[0]?.createdAt, 153 | lastReplyOn: 154 | res.node.comments.nodes[res.node.comments.nodes.length - 1]?.createdAt, 155 | resolvedAt: res.node.closedAt, 156 | participants: { deleteMany: {} }, 157 | }, 158 | }, 159 | }, 160 | include: { actionItem: { include: { participants: true } } }, 161 | }); 162 | 163 | const logins = res.node.participants.nodes.map((node) => node.login); 164 | await syncGithubParticipants(logins, githubItem.actionItem!.id); 165 | indexDocument(githubItem.actionItem!.id, { timesResolved: 1 }); 166 | } 167 | 168 | console.log(`✅ DONE: ${owner}/${name} ✅`); 169 | }) 170 | ); 171 | 172 | console.log(`✅ DONE: ${file} ✅`); 173 | }) 174 | ); 175 | 176 | console.log("✅✅✅✅ Syncing done ✅✅✅✅"); 177 | 178 | return { response: "ok" }; 179 | } catch (err) { 180 | console.log("❌❌❌❌ Syncing failed ❌❌❌❌"); 181 | console.error(err); 182 | return { response: err.message }; 183 | } 184 | }, 185 | async assignActionItem(req) { 186 | const { actionId, userId } = req; 187 | const user = await prisma.user.findFirst({ where: { slackId: userId } }); 188 | if (!user) return { response: "User not found" }; 189 | 190 | const actionItem = await prisma.actionItem.findFirst({ where: { id: actionId } }); 191 | if (!actionItem) return { response: "Action item not found" }; 192 | 193 | await prisma.actionItem.update({ 194 | where: { id: actionId }, 195 | data: { assigneeId: user.id, assignedOn: new Date() }, 196 | }); 197 | 198 | return { response: "ok" }; 199 | }, 200 | async resolveActionItem(req) { 201 | const { actionId, reason } = req; 202 | 203 | const actionItem = await prisma.actionItem.findFirst({ where: { id: actionId } }); 204 | if (!actionItem) return { response: "Action item not found" }; 205 | 206 | await prisma.actionItem.update({ 207 | where: { id: actionId }, 208 | data: { status: "closed", flag: "resolved", resolvedAt: new Date(), reason }, 209 | }); 210 | 211 | return { response: "ok" }; 212 | }, 213 | async irrelevantActionItem(req) { 214 | const { actionId, reason } = req; 215 | 216 | const actionItem = await prisma.actionItem.findFirst({ where: { id: actionId } }); 217 | if (!actionItem) return { response: "Action item not found" }; 218 | 219 | await prisma.actionItem.update({ 220 | where: { id: actionId }, 221 | data: { status: "closed", flag: "irrelevant", resolvedAt: new Date(), reason }, 222 | }); 223 | 224 | return { response: "ok" }; 225 | }, 226 | async updateNotes(req) { 227 | const { actionId, note } = req; 228 | 229 | await prisma.actionItem.update({ 230 | where: { id: actionId }, 231 | data: { notes: note }, 232 | }); 233 | 234 | return { response: "ok" }; 235 | }, 236 | async snoozeActionItem(req) { 237 | const { actionId, datetime, reason, userId } = req; 238 | 239 | const user = await prisma.user.findFirst({ where: { slackId: userId } }); 240 | if (!user) return { response: "User not found" }; 241 | 242 | await prisma.actionItem.update({ 243 | where: { id: actionId }, 244 | data: { 245 | snoozeCount: { increment: 1 }, 246 | snoozedUntil: new Date(datetime), 247 | snoozedById: user.id, 248 | reason, 249 | }, 250 | }); 251 | 252 | return { response: "ok" }; 253 | }, 254 | async followUpActionItem(req) { 255 | const { actionId, datetime, reason, userId } = req; 256 | 257 | const action = await prisma.actionItem.findFirst({ where: { id: actionId } }); 258 | if (!action) return { response: "Action item not found" }; 259 | 260 | const user = await prisma.user.findFirst({ where: { slackId: userId } }); 261 | if (!user) return { response: "User not found" }; 262 | 263 | const alreadyFollowingUp = await prisma.followUp.findFirst({ 264 | where: { parentId: actionId }, 265 | orderBy: { date: "desc" }, 266 | }); 267 | 268 | // We only update the current follow up if it's in the future, otherwise we always create a new one 269 | if (alreadyFollowingUp && alreadyFollowingUp.date > new Date()) { 270 | await prisma.followUp.update({ 271 | where: { 272 | parentId_nextItemId: { parentId: actionId, nextItemId: alreadyFollowingUp.nextItemId }, 273 | }, 274 | data: { 275 | date: datetime, 276 | nextItem: { 277 | create: { 278 | status: "followUp", 279 | totalReplies: 0, 280 | snoozedUntil: datetime, 281 | snoozedById: user.id, 282 | assigneeId: action.assigneeId, 283 | notes: reason ?? "", 284 | }, 285 | update: { 286 | status: "followUp", 287 | totalReplies: 0, 288 | snoozedUntil: datetime, 289 | snoozedById: user.id, 290 | assigneeId: action.assigneeId, 291 | notes: reason ?? "", 292 | }, 293 | }, 294 | }, 295 | }); 296 | } else { 297 | await prisma.followUp.create({ 298 | data: { 299 | parent: { connect: { id: actionId } }, 300 | date: datetime, 301 | nextItem: { 302 | create: { 303 | status: "followUp", 304 | totalReplies: 0, 305 | snoozedUntil: datetime, 306 | snoozedById: user.id, 307 | assigneeId: action.assigneeId, 308 | notes: reason ?? "", 309 | }, 310 | }, 311 | }, 312 | }); 313 | } 314 | 315 | return { response: "ok" }; 316 | }, 317 | async getSlackActionItem(req) { 318 | const { slackId } = req; 319 | 320 | const actionItem = await prisma.actionItem.findFirst({ 321 | where: { slackMessages: { some: { ts: slackId } } }, 322 | }); 323 | 324 | return { actionId: actionItem?.id }; 325 | }, 326 | }); 327 | -------------------------------------------------------------------------------- /lib/views.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Block, 3 | KnownBlock, 4 | Middleware, 5 | SlackViewAction, 6 | SlackViewMiddlewareArgs, 7 | } from "@slack/bolt"; 8 | import { StringIndexed } from "@slack/bolt/dist/types/helpers"; 9 | import dayjs from "dayjs"; 10 | import prisma from "./db"; 11 | import { indexDocument } from "./elastic"; 12 | import { getGithubItem } from "./octokit"; 13 | import { logActivity, syncGithubParticipants } from "./utils"; 14 | import metrics from "./metrics"; 15 | 16 | export const snoozeSubmit: Middleware< 17 | SlackViewMiddlewareArgs, 18 | StringIndexed 19 | > = async ({ ack, body, client, logger }) => { 20 | await ack(); 21 | const { user, view } = body; 22 | const { actionId, channelId, messageId } = JSON.parse(view.private_metadata); 23 | 24 | try { 25 | const reason = view.state.values.reason?.["reason-action"]?.value; 26 | const action = await prisma.actionItem.findFirstOrThrow({ where: { id: actionId } }); 27 | const { selected_date_time } = view.state.values.datetime["datetimepicker-action"]; 28 | const snoozedUntil = dayjs(selected_date_time, "X").toDate(); 29 | const dbUser = await prisma.user.findFirst({ where: { slackId: user.id } }); 30 | 31 | if (!dbUser) return; 32 | 33 | if (view.title.text === "Snooze") { 34 | await prisma.actionItem.update({ 35 | where: { id: actionId }, 36 | data: { 37 | snoozedUntil, 38 | snoozeCount: { increment: 1 }, 39 | snoozedById: dbUser?.id, 40 | reason: reason ?? "", 41 | }, 42 | }); 43 | } else { 44 | const alreadyFollowingUp = await prisma.followUp.findFirst({ 45 | where: { parentId: actionId }, 46 | orderBy: { date: "desc" }, 47 | }); 48 | 49 | // We only update the current follow up if it's in the future, otherwise we always create a new one 50 | if (alreadyFollowingUp && alreadyFollowingUp.date > new Date()) { 51 | await prisma.followUp.update({ 52 | where: { 53 | parentId_nextItemId: { parentId: actionId, nextItemId: alreadyFollowingUp.nextItemId }, 54 | }, 55 | data: { 56 | date: snoozedUntil, 57 | nextItem: { 58 | create: { 59 | status: "followUp", 60 | totalReplies: 0, 61 | snoozedUntil: snoozedUntil, 62 | snoozedById: dbUser?.id, 63 | assigneeId: action.assigneeId, 64 | notes: reason ?? "", 65 | }, 66 | update: { 67 | status: "followUp", 68 | totalReplies: 0, 69 | snoozedUntil: snoozedUntil, 70 | snoozedById: dbUser?.id, 71 | assigneeId: action.assigneeId, 72 | notes: reason ?? "", 73 | }, 74 | }, 75 | }, 76 | }); 77 | } else { 78 | await prisma.followUp.create({ 79 | data: { 80 | parent: { connect: { id: actionId } }, 81 | date: snoozedUntil, 82 | nextItem: { 83 | create: { 84 | status: "followUp", 85 | totalReplies: 0, 86 | snoozedUntil: snoozedUntil, 87 | snoozedById: dbUser?.id, 88 | assigneeId: action.assigneeId, 89 | notes: reason ?? "", 90 | }, 91 | }, 92 | }, 93 | }); 94 | } 95 | } 96 | 97 | await client.chat.postEphemeral({ 98 | channel: channelId, 99 | user: user.id, 100 | text: `:white_check_mark: Action item (id=${actionId}) ${ 101 | view.title.text === "Snooze" ? "snoozed until" : "will be followed up on" 102 | } ** ${ 105 | view.title.text === "Snooze" 106 | ? `by <@${user.id}> (Snooze count: ${action.snoozeCount + 1})` 107 | : "" 108 | }`, 109 | }); 110 | 111 | if (view.title.text !== "Snooze") return; 112 | 113 | const { messages } = await client.conversations.history({ 114 | channel: channelId, 115 | latest: messageId, 116 | limit: 1, 117 | inclusive: true, 118 | }); 119 | 120 | const blocks = messages?.[0].blocks || []; 121 | const idx = blocks.findIndex((block: any) => block.text && block.text.text.includes(actionId)); 122 | const newBlocks = blocks.filter((_, i) => i !== idx && i !== idx + 1) as (Block | KnownBlock)[]; 123 | 124 | await client.chat.update({ 125 | ts: messageId, 126 | channel: channelId, 127 | text: `Message updated: ${messageId}`, 128 | blocks: newBlocks, 129 | }); 130 | 131 | await indexDocument(actionId); 132 | await logActivity(client, user.id, action.id, "snoozed"); 133 | metrics.increment("slack.snooze.submit", 1); 134 | } catch (err) { 135 | metrics.increment("errors.slack.snooze", 1); 136 | await client.chat.postEphemeral({ 137 | channel: channelId, 138 | user: user.id, 139 | text: `:x: Failed to ${ 140 | view.title.text === "Snooze" ? "snooze" : "follow up on" 141 | } action item (id=${actionId}) ${err.message}`, 142 | }); 143 | logger.error(err); 144 | } 145 | }; 146 | 147 | export const irrelevantSubmit: Middleware< 148 | SlackViewMiddlewareArgs, 149 | StringIndexed 150 | > = async ({ ack, body, client, logger }) => { 151 | await ack(); 152 | const { user, view } = body; 153 | const { actionId, channelId, messageId } = JSON.parse(view.private_metadata); 154 | 155 | try { 156 | const reason = view.state.values.reason["reason-action"].value; 157 | 158 | const action = await prisma.actionItem.findFirst({ 159 | where: { id: actionId }, 160 | include: { 161 | slackMessages: { include: { channel: true } }, 162 | githubItems: { include: { repository: true } }, 163 | }, 164 | }); 165 | 166 | if (!action) return; 167 | 168 | if (action.githubItems.length > 0) { 169 | // * Github items are always singular for now 170 | const res = await getGithubItem( 171 | action.githubItems[0].repository.owner, 172 | action.githubItems[0].repository.name, 173 | action.githubItems[0].nodeId 174 | ); 175 | 176 | await prisma.githubItem.update({ 177 | where: { nodeId: action.githubItems[0].nodeId }, 178 | data: { 179 | state: "closed", 180 | actionItem: { 181 | update: { 182 | status: "closed", 183 | totalReplies: res.node.comments.totalCount, 184 | firstReplyOn: res.node.comments.nodes[0]?.createdAt, 185 | lastReplyOn: res.node.comments.nodes[res.node.comments.nodes.length - 1]?.createdAt, 186 | resolvedAt: new Date(), 187 | participants: { deleteMany: {} }, 188 | flag: "irrelevant", 189 | reason: reason ?? "", 190 | }, 191 | }, 192 | }, 193 | include: { actionItem: { include: { participants: true } } }, 194 | }); 195 | 196 | const logins = res.node.participants.nodes.map((node) => node.login); 197 | await syncGithubParticipants(logins, action.id); 198 | } else { 199 | await prisma.actionItem.update({ 200 | where: { id: action.id }, 201 | data: { 202 | status: "closed", 203 | resolvedAt: new Date(), 204 | flag: "irrelevant", 205 | reason: reason ?? "", 206 | }, 207 | }); 208 | } 209 | 210 | const { messages } = await client.conversations.history({ 211 | channel: channelId, 212 | latest: messageId, 213 | limit: 1, 214 | inclusive: true, 215 | }); 216 | 217 | const blocks = messages?.[0].blocks || []; 218 | const idx = blocks.findIndex((block: any) => block.text && block.text.text.includes(actionId)); 219 | const newBlocks = blocks.filter((_, i) => i !== idx && i !== idx + 1) as (Block | KnownBlock)[]; 220 | 221 | await client.chat.update({ 222 | ts: messageId, 223 | channel: channelId, 224 | text: `Message updated: ${messageId}`, 225 | blocks: newBlocks, 226 | }); 227 | 228 | await indexDocument(actionId); 229 | await logActivity(client, user.id, action.id, "irrelevant"); 230 | metrics.increment("slack.irrelevant.submit", 1); 231 | } catch (err) { 232 | metrics.increment("errors.slack.irrelevant", 1); 233 | await client.chat.postEphemeral({ 234 | channel: channelId, 235 | user: user.id, 236 | text: `:x: Failed to mark action item (id=${actionId}) as irrelevant ${err.message}`, 237 | }); 238 | logger.error(err); 239 | } 240 | }; 241 | 242 | export const resolveSubmit: Middleware< 243 | SlackViewMiddlewareArgs, 244 | StringIndexed 245 | > = async ({ ack, body, client, logger }) => { 246 | await ack(); 247 | 248 | const { user, view } = body; 249 | const { actionId, channelId, messageId } = JSON.parse(view.private_metadata); 250 | 251 | const reason = view.state.values.reason["reason-action"].value; 252 | 253 | const action = await prisma.actionItem.findUnique({ 254 | where: { id: actionId }, 255 | include: { 256 | slackMessages: { include: { channel: true } }, 257 | githubItems: { include: { repository: true } }, 258 | parentItems: { 259 | include: { 260 | parent: { 261 | include: { 262 | slackMessages: { include: { channel: true } }, 263 | githubItems: { include: { repository: true } }, 264 | }, 265 | }, 266 | }, 267 | orderBy: { date: "desc" }, 268 | take: 1, 269 | }, 270 | }, 271 | }); 272 | 273 | if (!action) return; 274 | 275 | try { 276 | if (action.githubItems.length > 0) { 277 | // * Github items are always singular for now 278 | const res = await getGithubItem( 279 | action.githubItems[0].repository.owner, 280 | action.githubItems[0].repository.name, 281 | action.githubItems[0].nodeId 282 | ); 283 | 284 | await prisma.githubItem.update({ 285 | where: { nodeId: action.githubItems[0].nodeId }, 286 | data: { 287 | state: "closed", 288 | actionItem: { 289 | update: { 290 | status: "closed", 291 | totalReplies: res.node.comments.totalCount, 292 | firstReplyOn: res.node.comments.nodes[0]?.createdAt, 293 | lastReplyOn: res.node.comments.nodes[res.node.comments.nodes.length - 1]?.createdAt, 294 | resolvedAt: new Date(), 295 | participants: { deleteMany: {} }, 296 | reason: reason ?? "", 297 | }, 298 | }, 299 | }, 300 | include: { actionItem: { include: { participants: true } } }, 301 | }); 302 | 303 | const logins = res.node.participants.nodes.map((node) => node.login); 304 | await syncGithubParticipants(logins, action.id); 305 | } else { 306 | await prisma.actionItem.update({ 307 | where: { id: action.id }, 308 | data: { status: "closed", resolvedAt: new Date(), reason: reason ?? "" }, 309 | }); 310 | } 311 | 312 | const isFollowUp = action.parentItems.length > 0; 313 | 314 | const { messages } = await client.conversations.history({ 315 | channel: channelId, 316 | latest: messageId, 317 | limit: 1, 318 | inclusive: true, 319 | }); 320 | 321 | const blocks = messages?.[0]?.blocks || []; 322 | const idx = blocks.findIndex((block: any) => block.text && block.text.text.includes(actionId)); 323 | const text = isFollowUp 324 | ? action.parentItems[0].parent.slackMessages?.[0]?.text || 325 | action.parentItems[0].parent.githubItems?.[0]?.title 326 | : action.slackMessages?.[0]?.text || action.githubItems?.[0]?.title; 327 | 328 | const newBlocks = blocks 329 | .map((b, i) => 330 | i === idx 331 | ? { 332 | type: "section", 333 | text: { 334 | type: "mrkdwn", 335 | text: `:white_check_mark: *Resolved* ${ 336 | text ? "(" + text.slice(0, 50) + ")..." : "" 337 | }`, 338 | }, 339 | accessory: { 340 | type: "button", 341 | text: { type: "plain_text", emoji: true, text: "Follow Up" }, 342 | value: isFollowUp ? action.parentItems[0].parentId : action.id, 343 | action_id: "followup", 344 | }, 345 | } 346 | : i === idx + 1 347 | ? null 348 | : b 349 | ) 350 | .filter((b) => b) as (Block | KnownBlock)[]; 351 | 352 | await client.chat.update({ 353 | ts: messageId, 354 | channel: channelId, 355 | text: `Message updated: ${messageId}`, 356 | blocks: newBlocks, 357 | }); 358 | 359 | await indexDocument(action.id, { timesResolved: 1 }); 360 | await logActivity(client, user.id, action.id, "resolved"); 361 | metrics.increment("slack.resolve.submit", 1); 362 | } catch (err) { 363 | metrics.increment("errors.slack.resolve", 1); 364 | await client.chat.postEphemeral({ 365 | channel: channelId, 366 | user: user.id, 367 | text: `:x: Failed to resolve action item (id=${actionId}) ${err.message}`, 368 | }); 369 | logger.error(err); 370 | } 371 | }; 372 | 373 | export const notesSubmit: Middleware< 374 | SlackViewMiddlewareArgs, 375 | StringIndexed 376 | > = async ({ ack, body, logger }) => { 377 | await ack(); 378 | 379 | try { 380 | const { view } = body; 381 | const { actionId } = JSON.parse(view.private_metadata); 382 | const notes = view.state.values.notes["notes-action"].value; 383 | await prisma.actionItem.update({ where: { id: actionId }, data: { notes: notes ?? "" } }); 384 | metrics.increment("slack.notes.submit", 1); 385 | } catch (err) { 386 | metrics.increment("errors.slack.notes", 1); 387 | logger.error(err); 388 | } 389 | }; 390 | -------------------------------------------------------------------------------- /gen/eliza_pb.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es v1.3.3 with parameter "target=ts" 2 | // @generated from file eliza.proto (package connectrpc.eliza.v1, syntax proto3) 3 | /* eslint-disable */ 4 | // @ts-nocheck 5 | 6 | import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; 7 | import { Message, proto3 } from "@bufbuild/protobuf"; 8 | 9 | /** 10 | * @generated from message connectrpc.eliza.v1.SyncRequest 11 | */ 12 | export class SyncRequest extends Message { 13 | /** 14 | * @generated from field: string project = 1; 15 | */ 16 | project = ""; 17 | 18 | constructor(data?: PartialMessage) { 19 | super(); 20 | proto3.util.initPartial(data, this); 21 | } 22 | 23 | static readonly runtime: typeof proto3 = proto3; 24 | static readonly typeName = "connectrpc.eliza.v1.SyncRequest"; 25 | static readonly fields: FieldList = proto3.util.newFieldList(() => [ 26 | { no: 1, name: "project", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 27 | ]); 28 | 29 | static fromBinary(bytes: Uint8Array, options?: Partial): SyncRequest { 30 | return new SyncRequest().fromBinary(bytes, options); 31 | } 32 | 33 | static fromJson(jsonValue: JsonValue, options?: Partial): SyncRequest { 34 | return new SyncRequest().fromJson(jsonValue, options); 35 | } 36 | 37 | static fromJsonString(jsonString: string, options?: Partial): SyncRequest { 38 | return new SyncRequest().fromJsonString(jsonString, options); 39 | } 40 | 41 | static equals(a: SyncRequest | PlainMessage | undefined, b: SyncRequest | PlainMessage | undefined): boolean { 42 | return proto3.util.equals(SyncRequest, a, b); 43 | } 44 | } 45 | 46 | /** 47 | * @generated from message connectrpc.eliza.v1.Empty 48 | */ 49 | export class Empty extends Message { 50 | constructor(data?: PartialMessage) { 51 | super(); 52 | proto3.util.initPartial(data, this); 53 | } 54 | 55 | static readonly runtime: typeof proto3 = proto3; 56 | static readonly typeName = "connectrpc.eliza.v1.Empty"; 57 | static readonly fields: FieldList = proto3.util.newFieldList(() => [ 58 | ]); 59 | 60 | static fromBinary(bytes: Uint8Array, options?: Partial): Empty { 61 | return new Empty().fromBinary(bytes, options); 62 | } 63 | 64 | static fromJson(jsonValue: JsonValue, options?: Partial): Empty { 65 | return new Empty().fromJson(jsonValue, options); 66 | } 67 | 68 | static fromJsonString(jsonString: string, options?: Partial): Empty { 69 | return new Empty().fromJsonString(jsonString, options); 70 | } 71 | 72 | static equals(a: Empty | PlainMessage | undefined, b: Empty | PlainMessage | undefined): boolean { 73 | return proto3.util.equals(Empty, a, b); 74 | } 75 | } 76 | 77 | /** 78 | * @generated from message connectrpc.eliza.v1.Response 79 | */ 80 | export class Response extends Message { 81 | /** 82 | * @generated from field: string response = 1; 83 | */ 84 | response = ""; 85 | 86 | constructor(data?: PartialMessage) { 87 | super(); 88 | proto3.util.initPartial(data, this); 89 | } 90 | 91 | static readonly runtime: typeof proto3 = proto3; 92 | static readonly typeName = "connectrpc.eliza.v1.Response"; 93 | static readonly fields: FieldList = proto3.util.newFieldList(() => [ 94 | { no: 1, name: "response", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 95 | ]); 96 | 97 | static fromBinary(bytes: Uint8Array, options?: Partial): Response { 98 | return new Response().fromBinary(bytes, options); 99 | } 100 | 101 | static fromJson(jsonValue: JsonValue, options?: Partial): Response { 102 | return new Response().fromJson(jsonValue, options); 103 | } 104 | 105 | static fromJsonString(jsonString: string, options?: Partial): Response { 106 | return new Response().fromJsonString(jsonString, options); 107 | } 108 | 109 | static equals(a: Response | PlainMessage | undefined, b: Response | PlainMessage | undefined): boolean { 110 | return proto3.util.equals(Response, a, b); 111 | } 112 | } 113 | 114 | /** 115 | * @generated from message connectrpc.eliza.v1.AssignRequest 116 | */ 117 | export class AssignRequest extends Message { 118 | /** 119 | * @generated from field: string actionId = 1; 120 | */ 121 | actionId = ""; 122 | 123 | /** 124 | * @generated from field: string userId = 2; 125 | */ 126 | userId = ""; 127 | 128 | constructor(data?: PartialMessage) { 129 | super(); 130 | proto3.util.initPartial(data, this); 131 | } 132 | 133 | static readonly runtime: typeof proto3 = proto3; 134 | static readonly typeName = "connectrpc.eliza.v1.AssignRequest"; 135 | static readonly fields: FieldList = proto3.util.newFieldList(() => [ 136 | { no: 1, name: "actionId", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 137 | { no: 2, name: "userId", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 138 | ]); 139 | 140 | static fromBinary(bytes: Uint8Array, options?: Partial): AssignRequest { 141 | return new AssignRequest().fromBinary(bytes, options); 142 | } 143 | 144 | static fromJson(jsonValue: JsonValue, options?: Partial): AssignRequest { 145 | return new AssignRequest().fromJson(jsonValue, options); 146 | } 147 | 148 | static fromJsonString(jsonString: string, options?: Partial): AssignRequest { 149 | return new AssignRequest().fromJsonString(jsonString, options); 150 | } 151 | 152 | static equals(a: AssignRequest | PlainMessage | undefined, b: AssignRequest | PlainMessage | undefined): boolean { 153 | return proto3.util.equals(AssignRequest, a, b); 154 | } 155 | } 156 | 157 | /** 158 | * @generated from message connectrpc.eliza.v1.ActionItemRequest 159 | */ 160 | export class ActionItemRequest extends Message { 161 | /** 162 | * @generated from field: string actionId = 1; 163 | */ 164 | actionId = ""; 165 | 166 | /** 167 | * @generated from field: optional string reason = 2; 168 | */ 169 | reason?: string; 170 | 171 | constructor(data?: PartialMessage) { 172 | super(); 173 | proto3.util.initPartial(data, this); 174 | } 175 | 176 | static readonly runtime: typeof proto3 = proto3; 177 | static readonly typeName = "connectrpc.eliza.v1.ActionItemRequest"; 178 | static readonly fields: FieldList = proto3.util.newFieldList(() => [ 179 | { no: 1, name: "actionId", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 180 | { no: 2, name: "reason", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, 181 | ]); 182 | 183 | static fromBinary(bytes: Uint8Array, options?: Partial): ActionItemRequest { 184 | return new ActionItemRequest().fromBinary(bytes, options); 185 | } 186 | 187 | static fromJson(jsonValue: JsonValue, options?: Partial): ActionItemRequest { 188 | return new ActionItemRequest().fromJson(jsonValue, options); 189 | } 190 | 191 | static fromJsonString(jsonString: string, options?: Partial): ActionItemRequest { 192 | return new ActionItemRequest().fromJsonString(jsonString, options); 193 | } 194 | 195 | static equals(a: ActionItemRequest | PlainMessage | undefined, b: ActionItemRequest | PlainMessage | undefined): boolean { 196 | return proto3.util.equals(ActionItemRequest, a, b); 197 | } 198 | } 199 | 200 | /** 201 | * @generated from message connectrpc.eliza.v1.SlackActionItemRequest 202 | */ 203 | export class SlackActionItemRequest extends Message { 204 | /** 205 | * @generated from field: string slackId = 1; 206 | */ 207 | slackId = ""; 208 | 209 | constructor(data?: PartialMessage) { 210 | super(); 211 | proto3.util.initPartial(data, this); 212 | } 213 | 214 | static readonly runtime: typeof proto3 = proto3; 215 | static readonly typeName = "connectrpc.eliza.v1.SlackActionItemRequest"; 216 | static readonly fields: FieldList = proto3.util.newFieldList(() => [ 217 | { no: 1, name: "slackId", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 218 | ]); 219 | 220 | static fromBinary(bytes: Uint8Array, options?: Partial): SlackActionItemRequest { 221 | return new SlackActionItemRequest().fromBinary(bytes, options); 222 | } 223 | 224 | static fromJson(jsonValue: JsonValue, options?: Partial): SlackActionItemRequest { 225 | return new SlackActionItemRequest().fromJson(jsonValue, options); 226 | } 227 | 228 | static fromJsonString(jsonString: string, options?: Partial): SlackActionItemRequest { 229 | return new SlackActionItemRequest().fromJsonString(jsonString, options); 230 | } 231 | 232 | static equals(a: SlackActionItemRequest | PlainMessage | undefined, b: SlackActionItemRequest | PlainMessage | undefined): boolean { 233 | return proto3.util.equals(SlackActionItemRequest, a, b); 234 | } 235 | } 236 | 237 | /** 238 | * @generated from message connectrpc.eliza.v1.SlackActionItemResponse 239 | */ 240 | export class SlackActionItemResponse extends Message { 241 | /** 242 | * @generated from field: string actionId = 1; 243 | */ 244 | actionId = ""; 245 | 246 | constructor(data?: PartialMessage) { 247 | super(); 248 | proto3.util.initPartial(data, this); 249 | } 250 | 251 | static readonly runtime: typeof proto3 = proto3; 252 | static readonly typeName = "connectrpc.eliza.v1.SlackActionItemResponse"; 253 | static readonly fields: FieldList = proto3.util.newFieldList(() => [ 254 | { no: 1, name: "actionId", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 255 | ]); 256 | 257 | static fromBinary(bytes: Uint8Array, options?: Partial): SlackActionItemResponse { 258 | return new SlackActionItemResponse().fromBinary(bytes, options); 259 | } 260 | 261 | static fromJson(jsonValue: JsonValue, options?: Partial): SlackActionItemResponse { 262 | return new SlackActionItemResponse().fromJson(jsonValue, options); 263 | } 264 | 265 | static fromJsonString(jsonString: string, options?: Partial): SlackActionItemResponse { 266 | return new SlackActionItemResponse().fromJsonString(jsonString, options); 267 | } 268 | 269 | static equals(a: SlackActionItemResponse | PlainMessage | undefined, b: SlackActionItemResponse | PlainMessage | undefined): boolean { 270 | return proto3.util.equals(SlackActionItemResponse, a, b); 271 | } 272 | } 273 | 274 | /** 275 | * @generated from message connectrpc.eliza.v1.NoteRequest 276 | */ 277 | export class NoteRequest extends Message { 278 | /** 279 | * @generated from field: string actionId = 1; 280 | */ 281 | actionId = ""; 282 | 283 | /** 284 | * @generated from field: string note = 2; 285 | */ 286 | note = ""; 287 | 288 | constructor(data?: PartialMessage) { 289 | super(); 290 | proto3.util.initPartial(data, this); 291 | } 292 | 293 | static readonly runtime: typeof proto3 = proto3; 294 | static readonly typeName = "connectrpc.eliza.v1.NoteRequest"; 295 | static readonly fields: FieldList = proto3.util.newFieldList(() => [ 296 | { no: 1, name: "actionId", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 297 | { no: 2, name: "note", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 298 | ]); 299 | 300 | static fromBinary(bytes: Uint8Array, options?: Partial): NoteRequest { 301 | return new NoteRequest().fromBinary(bytes, options); 302 | } 303 | 304 | static fromJson(jsonValue: JsonValue, options?: Partial): NoteRequest { 305 | return new NoteRequest().fromJson(jsonValue, options); 306 | } 307 | 308 | static fromJsonString(jsonString: string, options?: Partial): NoteRequest { 309 | return new NoteRequest().fromJsonString(jsonString, options); 310 | } 311 | 312 | static equals(a: NoteRequest | PlainMessage | undefined, b: NoteRequest | PlainMessage | undefined): boolean { 313 | return proto3.util.equals(NoteRequest, a, b); 314 | } 315 | } 316 | 317 | /** 318 | * @generated from message connectrpc.eliza.v1.DelayRequest 319 | */ 320 | export class DelayRequest extends Message { 321 | /** 322 | * @generated from field: string actionId = 1; 323 | */ 324 | actionId = ""; 325 | 326 | /** 327 | * @generated from field: string userId = 2; 328 | */ 329 | userId = ""; 330 | 331 | /** 332 | * @generated from field: string datetime = 3; 333 | */ 334 | datetime = ""; 335 | 336 | /** 337 | * @generated from field: string reason = 4; 338 | */ 339 | reason = ""; 340 | 341 | constructor(data?: PartialMessage) { 342 | super(); 343 | proto3.util.initPartial(data, this); 344 | } 345 | 346 | static readonly runtime: typeof proto3 = proto3; 347 | static readonly typeName = "connectrpc.eliza.v1.DelayRequest"; 348 | static readonly fields: FieldList = proto3.util.newFieldList(() => [ 349 | { no: 1, name: "actionId", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 350 | { no: 2, name: "userId", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 351 | { no: 3, name: "datetime", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 352 | { no: 4, name: "reason", kind: "scalar", T: 9 /* ScalarType.STRING */ }, 353 | ]); 354 | 355 | static fromBinary(bytes: Uint8Array, options?: Partial): DelayRequest { 356 | return new DelayRequest().fromBinary(bytes, options); 357 | } 358 | 359 | static fromJson(jsonValue: JsonValue, options?: Partial): DelayRequest { 360 | return new DelayRequest().fromJson(jsonValue, options); 361 | } 362 | 363 | static fromJsonString(jsonString: string, options?: Partial): DelayRequest { 364 | return new DelayRequest().fromJsonString(jsonString, options); 365 | } 366 | 367 | static equals(a: DelayRequest | PlainMessage | undefined, b: DelayRequest | PlainMessage | undefined): boolean { 368 | return proto3.util.equals(DelayRequest, a, b); 369 | } 370 | } 371 | 372 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import fs, { readFileSync, readdirSync } from "fs"; 2 | import yaml from "js-yaml"; 3 | import { slack } from ".."; 4 | import { buttons, githubItem, slackItem } from "./blocks"; 5 | import prisma from "./db"; 6 | import { indexDocument } from "./elastic"; 7 | import { Config, Maintainer } from "./types"; 8 | 9 | export const MAINTAINERS = yaml.load(readFileSync(`./maintainers.yaml`, "utf-8")) as Maintainer[]; 10 | 11 | export const joinChannels = async () => { 12 | const files = fs.readdirSync("./config"); 13 | 14 | files.forEach(async (file) => { 15 | try { 16 | const config = getYamlFile(file); 17 | const channels = config.channels || []; 18 | 19 | for (let i = 0; i < channels.length; i++) { 20 | const channel = channels[i]; 21 | 22 | await prisma.channel.upsert({ 23 | where: { slackId: channel.id }, 24 | update: { name: channel.name }, 25 | create: { name: channel.name, slackId: channel.id }, 26 | }); 27 | 28 | await slack.client.conversations.join({ channel: channel.id }); 29 | } 30 | } catch (err) { 31 | console.error(err); 32 | } 33 | }); 34 | }; 35 | 36 | export const getMaintainers = ({ 37 | channelId, 38 | repoUrl, 39 | }: { 40 | channelId?: string; 41 | repoUrl?: string; 42 | }) => { 43 | const files = fs.readdirSync("./config"); 44 | const arr: string[] = []; 45 | 46 | files.forEach((file) => { 47 | try { 48 | const config = getYamlFile(file); 49 | const maintainers = config["maintainers"]; 50 | const channels = config.channels || []; 51 | const repos = config["repos"]; 52 | 53 | if ( 54 | channels.some((channel) => channel.id === channelId) || 55 | repos.some((repo) => repo.uri === repoUrl) 56 | ) 57 | arr.push(...maintainers); 58 | } catch (err) {} 59 | }); 60 | 61 | return arr 62 | .map((id) => MAINTAINERS.find((user) => user.id === id)) 63 | .filter((user) => user) as Maintainer[]; 64 | }; 65 | 66 | export const getProjectName = ({ 67 | channelId, 68 | repoUrl, 69 | }: { 70 | channelId?: string; 71 | repoUrl?: string; 72 | }) => { 73 | const files = fs.readdirSync("./config"); 74 | let project: string | undefined; 75 | 76 | files.forEach((file) => { 77 | try { 78 | const config = getYamlFile(file); 79 | const channels = config.channels || []; 80 | const repos = config["repos"]; 81 | 82 | if ( 83 | channels.some((channel) => channel.id === channelId) || 84 | repos.some((repo) => repo.uri === repoUrl) 85 | ) 86 | project = file.replace(".yaml", ""); 87 | } catch (err) {} 88 | }); 89 | 90 | return project; 91 | }; 92 | 93 | export const syncParticipants = async (participants: string[], id: string) => { 94 | for (let i = 0; i < participants.length; i++) { 95 | const userInfo = await slack.client.users.info({ user: participants[i] as string }); 96 | const userId = await prisma.user 97 | .findFirst({ where: { slackId: participants[i] as string } }) 98 | .then((user) => user?.id || "-1"); 99 | 100 | await prisma.participant.upsert({ 101 | where: { userId_actionItemId: { userId, actionItemId: id } }, 102 | create: { 103 | actionItem: { connect: { id } }, 104 | user: { 105 | connectOrCreate: { 106 | where: { id: userId }, 107 | create: { 108 | slackId: participants[i] as string, 109 | email: userInfo.user?.profile?.email || "", 110 | githubUsername: MAINTAINERS.find((user) => user.slack === participants[i])?.github, 111 | }, 112 | }, 113 | }, 114 | }, 115 | update: {}, 116 | }); 117 | } 118 | }; 119 | 120 | export const syncGithubParticipants = async (participants: string[], id: string) => { 121 | for (let i = 0; i < participants.length; i++) { 122 | const user = await prisma.user.findFirst({ 123 | where: { 124 | OR: [{ githubUsername: participants[i] as string }, { email: participants[i] as string }], 125 | }, 126 | }); 127 | 128 | await prisma.participant.create({ 129 | data: { 130 | actionItem: { connect: { id } }, 131 | user: { 132 | connectOrCreate: { 133 | where: { id: user?.id || "-1" }, 134 | create: { githubUsername: participants[i] as string, email: participants[i] }, 135 | }, 136 | }, 137 | }, 138 | }); 139 | } 140 | }; 141 | 142 | export const getYamlFile = (filename: string) => { 143 | return yaml.load(readFileSync(`./config/${filename}`, "utf-8")) as Config; 144 | }; 145 | 146 | export const getProjectDetails = async ( 147 | project: string, 148 | user_id?: string, 149 | login?: string | null, 150 | checkMembership: boolean = true 151 | ) => { 152 | const files = readdirSync("./config"); 153 | let channels: Config["channels"] = []; 154 | let repositories: Config["repos"] = []; 155 | let maintainers: Config["maintainers"] = []; 156 | let sections: Config["sections"] = []; 157 | 158 | if (project === "all") { 159 | files.forEach((file) => { 160 | const config = getYamlFile(file); 161 | 162 | const topLevelMaintainers = config.maintainers.map((id) => 163 | MAINTAINERS.find((user) => user.id === id) 164 | ); 165 | 166 | if ( 167 | (checkMembership && 168 | topLevelMaintainers.some( 169 | (maintainer) => maintainer?.github === login || maintainer?.slack === user_id 170 | )) || 171 | !checkMembership 172 | ) { 173 | channels = [...(channels || []), ...(config.channels || [])]; 174 | repositories = [...repositories, ...config["repos"]]; 175 | maintainers = [...maintainers, ...config.maintainers]; 176 | sections = [...(sections || []), ...(config.sections || [])]; 177 | } 178 | }); 179 | } else { 180 | const config = getYamlFile(`${project}.yaml`); 181 | channels = config.channels || []; 182 | repositories = config["repos"]; 183 | maintainers = config.maintainers; 184 | sections = config.sections || []; 185 | } 186 | 187 | return { 188 | channels, 189 | repositories, 190 | maintainers: maintainers.map((id) => 191 | MAINTAINERS.find((user) => user.id === id) 192 | ) as Maintainer[], 193 | sections, 194 | }; 195 | }; 196 | 197 | export const checkNeedsNotifying = async (actionId: string) => { 198 | const item = await prisma.actionItem.findUnique({ 199 | where: { id: actionId }, 200 | include: { 201 | githubItems: { include: { repository: true, author: true } }, 202 | slackMessages: { include: { channel: true, author: true } }, 203 | assignee: true, 204 | }, 205 | }); 206 | 207 | if (!item) return; 208 | 209 | const msg = item?.slackMessages[0]; 210 | const gh = item?.githubItems[0]; 211 | 212 | const project = getProjectName({ channelId: msg?.channel.slackId, repoUrl: gh?.repository.url }); 213 | if (!project) return; 214 | 215 | const config = getYamlFile(`${project}.yaml`); 216 | let maintainers: Maintainer[] = []; 217 | 218 | if (gh) { 219 | const usersToNotify = config.repos?.find((r) => r.uri === gh.repository.url)?.notify || []; 220 | 221 | const section = config.sections?.filter((s) => { 222 | const regex = new RegExp(s.pattern); 223 | return regex.test(gh.title || "") || regex.test(gh.body || ""); 224 | }); 225 | 226 | const sectionUsers = section?.map((s) => s.notify).flat() || []; 227 | const allUsers = Array.from(new Set([...usersToNotify, ...sectionUsers])); 228 | 229 | maintainers = allUsers.map((id) => MAINTAINERS.find((user) => user.id === id) as Maintainer); 230 | } else if (msg) { 231 | const usersToNotify = config.channels?.find((c) => c.id === msg.channel.slackId)?.notify || []; 232 | 233 | const section = config.sections?.filter((s) => { 234 | const regex = new RegExp(s.pattern); 235 | return regex.test(msg.text || ""); 236 | }); 237 | 238 | const sectionUsers = section?.map((s) => s.notify).flat() || []; 239 | const allUsers = Array.from(new Set([...usersToNotify, ...sectionUsers])); 240 | 241 | maintainers = allUsers.map((id) => MAINTAINERS.find((user) => user.id === id) as Maintainer); 242 | } 243 | 244 | const arr: any[] = []; 245 | 246 | if (msg) arr.push(slackItem({ item })); 247 | if (gh) arr.push(githubItem({ item })); 248 | arr.push(...buttons({ item, showAssignee: true, showActions: true })); 249 | 250 | for await (const maintainer of maintainers) { 251 | if (!maintainer?.slack) continue; 252 | 253 | await slack.client.chat.postMessage({ 254 | channel: maintainer.slack, 255 | text: `Hey <@${maintainer.slack}>, you asked us to notify for a new action item:`, 256 | blocks: [ 257 | { 258 | type: "section", 259 | text: { 260 | type: "mrkdwn", 261 | text: `Hey <@${maintainer.slack}>, you asked us to notify for a new action item:`, 262 | }, 263 | }, 264 | { type: "divider" }, 265 | ...arr.flat(), 266 | ], 267 | }); 268 | } 269 | }; 270 | 271 | export const logActivity = async ( 272 | client: typeof slack.client, 273 | user: string, 274 | actionId: string, 275 | type: 276 | | "resolved" 277 | | "irrelevant" 278 | | "snoozed" 279 | | "reopened" 280 | | "unsnoozed" 281 | | "assigned" 282 | | "unassigned", 283 | notifyUser?: string 284 | ) => { 285 | try { 286 | if (process.env.ACTIVITY_LOG_CHANNEL_ID === undefined) return; 287 | 288 | const item = await prisma.actionItem.findUnique({ 289 | where: { id: actionId }, 290 | include: { 291 | githubItems: { include: { repository: true, author: true } }, 292 | slackMessages: { include: { channel: true, author: true } }, 293 | assignee: true, 294 | parentItems: { 295 | include: { 296 | parent: { 297 | include: { 298 | githubItems: { include: { author: true, repository: true } }, 299 | slackMessages: { include: { author: true, channel: true } }, 300 | participants: { include: { user: true } }, 301 | assignee: true, 302 | }, 303 | }, 304 | }, 305 | }, 306 | }, 307 | }); 308 | 309 | const msg = item?.slackMessages[0] || item?.parentItems[0]?.parent?.slackMessages[0]; 310 | const gh = item?.githubItems[0] || item?.parentItems[0]?.parent?.githubItems[0]; 311 | 312 | const project = getProjectName({ 313 | channelId: msg?.channel.slackId, 314 | repoUrl: gh?.repository.url, 315 | }); 316 | 317 | if (!project) return; 318 | 319 | const config = getYamlFile(`${project}.yaml`); 320 | if (!item || config.private) return; 321 | 322 | const url = gh 323 | ? `https://github.com/${gh.repository.owner}/${gh.repository.name}/issues/${gh.number}` 324 | : msg 325 | ? `https://hackclub.slack.com/archives/${msg.channel.slackId}/p${msg.ts.replace(".", "")}` 326 | : undefined; 327 | 328 | await client.chat.postMessage({ 329 | channel: process.env.ACTIVITY_LOG_CHANNEL_ID, 330 | text: `:white_check_mark: ${ 331 | MAINTAINERS.find((u) => u.slack === user)?.id || user 332 | } ${type} an action item. ${ 333 | type === "irrelevant" || type === "resolved" ? `\n\nReason: ${item.reason}` : "" 334 | }\n\n${url ? `<${url}|View action item>` : ""} id=${actionId}`, 335 | }); 336 | 337 | if (notifyUser && user !== notifyUser && type === "assigned") { 338 | const arr: any[] = []; 339 | 340 | if (msg) arr.push(slackItem({ item })); 341 | if (gh) arr.push(githubItem({ item })); 342 | arr.push( 343 | ...buttons({ 344 | item, 345 | showAssignee: true, 346 | showActions: true, 347 | followUpId: item.parentItems.length > 0 ? item.id : undefined, 348 | }) 349 | ); 350 | 351 | await client.chat.postMessage({ 352 | channel: notifyUser, 353 | unfurl_links: false, 354 | text: `Hey <@${notifyUser}>, ${ 355 | MAINTAINERS.find((u) => u.slack === user)?.id || user 356 | } ${type} an action item to you:`, 357 | blocks: [ 358 | { 359 | type: "section", 360 | text: { 361 | type: "mrkdwn", 362 | text: `Hey <@${notifyUser}>, ${ 363 | MAINTAINERS.find((u) => u.slack === user)?.id || user 364 | } ${type} an action item to you:`, 365 | }, 366 | }, 367 | { type: "divider" }, 368 | ...arr.flat(), 369 | ], 370 | }); 371 | } 372 | } catch (err) { 373 | console.error(err); 374 | } 375 | }; 376 | 377 | export const checkDuplicateResources = async () => { 378 | console.log("⏳⏳ Checking for duplicates ⏳⏳"); 379 | const { channels, repositories } = await getProjectDetails("all", undefined, null, false); 380 | 381 | const hasChannelDuplicates = channels.some( 382 | (channel) => channels.filter((c) => c.id === channel.id).length > 1 383 | ); 384 | 385 | const hasRepoDuplicates = repositories.some( 386 | (repo) => repositories.filter((r) => r.uri === repo.uri).length > 1 387 | ); 388 | 389 | if (hasChannelDuplicates || hasRepoDuplicates) { 390 | console.log("🚨🚨 Found duplicates. Aborting 🚨🚨"); 391 | console.log("Channels:"); 392 | console.log( 393 | channels.filter((channel) => channels.filter((c) => c.id === channel.id).length > 1) 394 | ); 395 | console.log("Repositories:"); 396 | console.log( 397 | repositories.filter((repo) => repositories.filter((r) => r.uri === repo.uri).length > 1) 398 | ); 399 | 400 | process.exit(1); 401 | } 402 | 403 | console.log("✅✅ No duplicates found ✅✅"); 404 | }; 405 | 406 | export const backFill = async () => { 407 | // await elastic.indices.delete({ index: "search-slacker-analytics" }); 408 | // await elastic.indices.create({ index: "search-slacker-analytics" }); 409 | 410 | const actionItems = await prisma.actionItem.findMany({ select: { id: true } }); 411 | const batchSize = 10; // Set the desired batch size 412 | 413 | const chunk = (array: T[], size: number): T[][] => { 414 | return Array.from({ length: Math.ceil(array.length / size) }, (_, index) => 415 | array.slice(index * size, index * size + size) 416 | ); 417 | }; 418 | 419 | const backfillBatch = async (batch: { id: string }[]) => { 420 | await Promise.allSettled( 421 | batch.map(async (item, index) => { 422 | await indexDocument(item.id); 423 | }) 424 | ); 425 | }; 426 | 427 | const batches = chunk(actionItems, batchSize); 428 | 429 | for (const batch of batches) { 430 | console.log(`Backfilling batch #${batches.indexOf(batch) + 1}/${batches.length}`); 431 | await backfillBatch(batch); 432 | } 433 | }; 434 | -------------------------------------------------------------------------------- /lib/blocks.ts: -------------------------------------------------------------------------------- 1 | import { ActionItem, Channel, GithubItem, Repository, SlackMessage, User } from "@prisma/client"; 2 | import dayjs from "dayjs"; 3 | import metrics from "./metrics"; 4 | import { getMaintainers, getProjectName } from "./utils"; 5 | 6 | export const slackItem = ({ 7 | item, 8 | showActions = true, 9 | followUp, 10 | }: { 11 | item: ActionItem & { 12 | assignee: User | null | undefined; 13 | githubItems: (GithubItem & { repository: Repository })[]; 14 | slackMessages: (SlackMessage & { channel: Channel; author: User })[]; 15 | }; 16 | showActions?: boolean; 17 | followUp?: { id: string; duration: number }; 18 | }) => { 19 | const diff = dayjs().diff(dayjs(item.lastReplyOn), "day"); 20 | const project = getProjectName({ channelId: item.slackMessages[0].channel?.slackId }); 21 | 22 | const assigneeText = item.assignee 23 | ? `Assigned to: ${ 24 | item.assignee.slackId ? `<@${item.assignee.slackId}>` : item.assignee.githubUsername 25 | }` 26 | : "Unassigned"; 27 | 28 | const maintainers = getMaintainers({ channelId: item.slackMessages[0].channel?.slackId }); 29 | const isMaintainer = maintainers.find( 30 | (maintainer) => 31 | maintainer?.slack === item.assignee?.slackId || 32 | maintainer?.github === item.assignee?.githubUsername 33 | ); 34 | 35 | const currentAssignee = 36 | item.assignee && isMaintainer 37 | ? isMaintainer 38 | : item.assignee 39 | ? { 40 | id: item.assignee?.id, 41 | slack: item.assignee?.slackId, 42 | github: item.assignee?.githubUsername, 43 | } 44 | : undefined; 45 | 46 | const text = item.slackMessages 47 | .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) 48 | .map((m) => `<@${m.author?.slackId}>: ${m.text}`) 49 | .join("\n"); 50 | 51 | return { 52 | type: "section", 53 | text: { 54 | type: "mrkdwn", 55 | text: `${ 56 | followUp?.id 57 | ? `:information_source: Follow up (${followUp.duration} days) :information_source:` 58 | : "" 59 | }\n*Project:* ${project}\n*Action Id:* ${ 60 | followUp?.id ? followUp.id : item.id 61 | }\n*Query:* ${text.slice(0, 2000)}${text.length > 2000 ? "..." : ""}\n\nOpened by <@${ 62 | item.slackMessages[0].author?.slackId 63 | }> on ${dayjs(item.slackMessages[0].createdAt).format("MMM DD, YYYY")} at ${dayjs( 64 | item.slackMessages[0].createdAt 65 | ).format("hh:mm A")}${ 66 | item.lastReplyOn 67 | ? `\n*Last reply:* ${dayjs(item.lastReplyOn).fromNow()} ${diff > 10 ? ":panik:" : ""}` 68 | : "\n:panik: *No replies yet*" 69 | } | ${assigneeText}\n`, 72 | }, 73 | accessory: showActions 74 | ? { 75 | type: "button", 76 | text: { type: "plain_text", emoji: true, text: "Resolve" }, 77 | style: "primary", 78 | value: followUp?.id ? followUp.id : item.id, 79 | action_id: "resolve", 80 | } 81 | : { 82 | type: "static_select", 83 | placeholder: { type: "plain_text", text: "Assign to", emoji: true }, 84 | options: maintainers 85 | .filter((m) => !!m) 86 | .map((maintainer) => ({ 87 | text: { type: "plain_text", text: maintainer!.id, emoji: true }, 88 | value: `${followUp?.id ? followUp.id : item.id}-${maintainer?.id}`, 89 | })) 90 | .concat( 91 | followUp?.id 92 | ? [] 93 | : { 94 | text: { type: "plain_text", text: "none", emoji: true }, 95 | value: `${item.id}-unassigned`, 96 | } 97 | ) 98 | .concat( 99 | ...(item.assignee && !isMaintainer 100 | ? [ 101 | { 102 | text: { 103 | type: "plain_text", 104 | text: `<@${currentAssignee?.slack}> (volunteer)`, 105 | emoji: true, 106 | }, 107 | value: `${followUp?.id ? followUp.id : item.id}-${currentAssignee?.id}`, 108 | }, 109 | ] 110 | : []) 111 | ), 112 | initial_option: currentAssignee 113 | ? { 114 | text: { 115 | type: "plain_text", 116 | text: isMaintainer 117 | ? currentAssignee.id 118 | : `<@${currentAssignee.slack}> (volunteer)`, 119 | emoji: true, 120 | }, 121 | value: `${followUp?.id ? followUp.id : item.id}-${currentAssignee.id}`, 122 | } 123 | : followUp?.id 124 | ? undefined 125 | : { 126 | text: { type: "plain_text", text: "none", emoji: true }, 127 | value: `${item.id}-unassigned`, 128 | }, 129 | action_id: "assigned", 130 | }, 131 | }; 132 | }; 133 | 134 | export const githubItem = ({ 135 | item, 136 | showActions = true, 137 | followUp, 138 | }: { 139 | item: ActionItem & { 140 | assignee: User | null | undefined; 141 | githubItems: (GithubItem & { repository: Repository; author: User })[]; 142 | slackMessages: (SlackMessage & { channel: Channel; author: User })[]; 143 | }; 144 | showActions?: boolean; 145 | followUp?: { id: string; duration: number }; 146 | }) => { 147 | const diff = dayjs().diff(dayjs(item.lastReplyOn), "day"); 148 | const url = ``; 149 | const text = item.githubItems[0].title ? `*Issue:* ${item.githubItems[0].title}` : url; 150 | const project = getProjectName({ repoUrl: item.githubItems[0].repository?.url }); 151 | 152 | const assigneeText = item.assignee 153 | ? `Assigned to ${ 154 | item.assignee.slackId ? `<@${item.assignee.slackId}>` : item.assignee.githubUsername 155 | }` 156 | : "Unassigned"; 157 | 158 | const maintainers = getMaintainers({ repoUrl: item.githubItems[0].repository?.url }); 159 | const isMaintainer = maintainers.find( 160 | (maintainer) => 161 | maintainer?.slack === item.assignee?.slackId || 162 | maintainer?.github === item.assignee?.githubUsername 163 | ); 164 | 165 | const currentAssignee = 166 | item.assignee && isMaintainer 167 | ? isMaintainer 168 | : item.assignee 169 | ? { 170 | id: item.assignee?.id, 171 | slack: item.assignee?.slackId, 172 | github: item.assignee?.githubUsername, 173 | } 174 | : undefined; 175 | 176 | return { 177 | type: "section", 178 | text: { 179 | type: "mrkdwn", 180 | text: `${ 181 | followUp?.id 182 | ? `:information_source: Follow up (${followUp.duration} days) :information_source:` 183 | : "" 184 | }\n*Project:* ${project}\n*Action Id:* ${ 185 | followUp?.id ? followUp.id : item.id 186 | }\n${text}\n\nOpened by ${item.githubItems[0].author?.githubUsername} on ${dayjs( 187 | item.githubItems[0].createdAt 188 | ).format("MMM DD, YYYY")} at ${dayjs(item.githubItems[0].createdAt).format("hh:mm A")}${ 189 | item.lastReplyOn 190 | ? `\n*Last reply:* ${dayjs(item.lastReplyOn).fromNow()} ${diff > 10 ? ":panik:" : ""}` 191 | : "\n:panik: *No replies yet*" 192 | } | ${assigneeText}\n${item.githubItems[0].title ? url : ""}`, 193 | }, 194 | accessory: showActions 195 | ? { 196 | type: "button", 197 | text: { type: "plain_text", emoji: true, text: "Resolve" }, 198 | style: "primary", 199 | value: followUp?.id ? followUp.id : item.id, 200 | action_id: "resolve", 201 | } 202 | : { 203 | type: "static_select", 204 | placeholder: { type: "plain_text", text: "Assign to", emoji: true }, 205 | options: maintainers 206 | .filter((m) => !!m) 207 | .map((maintainer) => ({ 208 | text: { type: "plain_text", text: maintainer!.id, emoji: true }, 209 | value: `${followUp?.id ? followUp.id : item.id}-${maintainer?.id}`, 210 | })) 211 | .concat( 212 | followUp?.id 213 | ? [] 214 | : { 215 | text: { type: "plain_text", text: "none", emoji: true }, 216 | value: `${item.id}-unassigned`, 217 | } 218 | ) 219 | .concat( 220 | ...(item.assignee && !isMaintainer 221 | ? [ 222 | { 223 | text: { 224 | type: "plain_text", 225 | text: `<@${currentAssignee?.slack}> (volunteer)`, 226 | emoji: true, 227 | }, 228 | value: `${followUp?.id ? followUp.id : item.id}-${currentAssignee?.id}`, 229 | }, 230 | ] 231 | : []) 232 | ), 233 | initial_option: currentAssignee 234 | ? { 235 | text: { 236 | type: "plain_text", 237 | text: isMaintainer 238 | ? currentAssignee.id 239 | : `<@${currentAssignee.slack}> (volunteer)`, 240 | emoji: true, 241 | }, 242 | value: `${followUp?.id ? followUp.id : item.id}-${currentAssignee.id}`, 243 | } 244 | : followUp?.id 245 | ? undefined 246 | : { 247 | text: { type: "plain_text", text: "none", emoji: true }, 248 | value: `${item.id}-unassigned`, 249 | }, 250 | action_id: "assigned", 251 | }, 252 | }; 253 | }; 254 | 255 | export const buttons = ({ 256 | item, 257 | showAssignee = false, 258 | showActions = true, 259 | followUpId, 260 | }: { 261 | item: ActionItem & { 262 | assignee: User | null | undefined; 263 | githubItems: (GithubItem & { repository: Repository; author: User })[]; 264 | slackMessages: (SlackMessage & { channel: Channel; author: User })[]; 265 | }; 266 | showAssignee?: boolean; 267 | showActions?: boolean; 268 | followUpId?: string; 269 | }) => { 270 | const maintainers = getMaintainers({ 271 | repoUrl: item.githubItems[0]?.repository?.url, 272 | channelId: item.slackMessages[0]?.channel?.slackId, 273 | }); 274 | const isMaintainer = maintainers.find( 275 | (maintainer) => 276 | maintainer?.slack === item.assignee?.slackId || 277 | maintainer?.github === item.assignee?.githubUsername 278 | ); 279 | 280 | const currentAssignee = 281 | item.assignee && isMaintainer 282 | ? isMaintainer 283 | : item.assignee 284 | ? { 285 | id: item.assignee?.id, 286 | slack: item.assignee?.slackId, 287 | github: item.assignee?.githubUsername, 288 | } 289 | : undefined; 290 | 291 | return [ 292 | { 293 | type: "actions", 294 | elements: [ 295 | ...(showActions 296 | ? [ 297 | { 298 | type: "button", 299 | text: { type: "plain_text", emoji: true, text: "Snooze" }, 300 | value: followUpId ?? item.id, 301 | action_id: "snooze", 302 | }, 303 | { 304 | type: "button", 305 | text: { type: "plain_text", emoji: true, text: "Follow Up" }, 306 | value: item.id, 307 | action_id: "followup", 308 | }, 309 | { 310 | type: "button", 311 | text: { type: "plain_text", emoji: true, text: "Close - Irrelevant" }, 312 | value: followUpId ?? item.id, 313 | action_id: "irrelevant", 314 | }, 315 | ] 316 | : []), 317 | { 318 | type: "button", 319 | text: { 320 | type: "plain_text", 321 | emoji: true, 322 | text: `Notes ${item.notes.length > 0 ? "(👀)" : ""}`, 323 | }, 324 | value: followUpId ?? item.id, 325 | action_id: "notes", 326 | }, 327 | ...(showAssignee 328 | ? [ 329 | { 330 | type: "static_select", 331 | placeholder: { type: "plain_text", text: "Assign to", emoji: true }, 332 | options: maintainers 333 | .filter((m) => !!m) 334 | .map((maintainer) => ({ 335 | text: { type: "plain_text", text: maintainer!.id, emoji: true }, 336 | value: `${followUpId ?? item.id}-${maintainer?.id}`, 337 | })) 338 | .concat( 339 | followUpId 340 | ? [] 341 | : { 342 | text: { type: "plain_text", text: "none", emoji: true }, 343 | value: `${item.id}-unassigned`, 344 | } 345 | ) 346 | .concat( 347 | ...(item.assignee && !isMaintainer 348 | ? [ 349 | { 350 | text: { 351 | type: "plain_text", 352 | text: `<@${currentAssignee?.slack}> (volunteer)`, 353 | emoji: true, 354 | }, 355 | value: `${followUpId ?? item.id}-${currentAssignee?.id}`, 356 | }, 357 | ] 358 | : []) 359 | ), 360 | initial_option: currentAssignee 361 | ? { 362 | text: { 363 | type: "plain_text", 364 | text: isMaintainer 365 | ? currentAssignee.id 366 | : `<@${currentAssignee.slack}> (volunteer)`, 367 | emoji: true, 368 | }, 369 | value: `${followUpId ?? item.id}-${currentAssignee.id}`, 370 | } 371 | : followUpId 372 | ? undefined 373 | : { 374 | text: { type: "plain_text", text: "none", emoji: true }, 375 | value: `${followUpId ?? item.id}-unassigned`, 376 | }, 377 | action_id: "assigned", 378 | }, 379 | ] 380 | : []), 381 | ], 382 | }, 383 | { type: "divider" }, 384 | ]; 385 | }; 386 | 387 | export const unauthorizedError = async ({ client, user_id, channel_id }) => { 388 | metrics.increment("errors.unauthorized", 1); 389 | await client.chat.postEphemeral({ 390 | user: user_id, 391 | channel: channel_id, 392 | text: `:warning: You're not a manager for this project. Make sure you're listed inside the config/[project].yaml file.`, 393 | }); 394 | }; 395 | -------------------------------------------------------------------------------- /lib/octokit.ts: -------------------------------------------------------------------------------- 1 | import { createAppAuth } from "@octokit/auth-app"; 2 | import { Webhooks } from "@octokit/webhooks"; 3 | import { 4 | ActionItem, 5 | Channel, 6 | GithubItem, 7 | GithubItemType, 8 | Repository, 9 | SlackMessage, 10 | User, 11 | } from "@prisma/client"; 12 | import { Octokit } from "octokit"; 13 | import { slack } from ".."; 14 | import prisma from "./db"; 15 | import { indexDocument } from "./elastic"; 16 | import metrics from "./metrics"; 17 | import { GithubData, SingleIssueOrPullData } from "./types"; 18 | import { MAINTAINERS, getMaintainers, getProjectName } from "./utils"; 19 | 20 | const appId = process.env.GITHUB_APP_ID || ""; 21 | const base64 = process.env.GITHUB_PRIVATE_KEY || ""; 22 | const privateKey = Buffer.from(base64, "base64").toString("utf-8"); 23 | 24 | export const webhooks = new Webhooks({ secret: process.env.GITHUB_WEBHOOK_SECRET || "" }); 25 | 26 | webhooks.on("issues.opened", async ({ payload }) => createGithubItem(payload)); 27 | webhooks.on("pull_request.opened", async ({ payload }) => createGithubItem(payload)); 28 | webhooks.on("pull_request.review_requested", async ({ payload }) => { 29 | metrics.increment("octokit.pull_request.review_requested"); 30 | 31 | const { pull_request, repository, sender, requested_reviewer } = payload as typeof payload & { 32 | requested_reviewer?: { login: string }; 33 | }; 34 | 35 | const project = getProjectName({ repoUrl: repository.html_url }); 36 | if (!project || !requested_reviewer) return; 37 | 38 | const user = 39 | MAINTAINERS.find((maintainer) => maintainer.github === requested_reviewer.login)?.slack || 40 | (await prisma.user.findFirst({ where: { githubUsername: requested_reviewer.login } }))?.slackId; 41 | 42 | if (sender.login === requested_reviewer.login) return; 43 | 44 | if (user) { 45 | await slack.client.chat.postMessage({ 46 | channel: user, 47 | text: `You have been requested to review a pull request on ${project} by ${sender.login}.\n${pull_request.html_url}`, 48 | }); 49 | } else { 50 | console.log("No user found for", requested_reviewer); 51 | } 52 | }); 53 | 54 | webhooks.on("workflow_job", async ({ payload }) => { 55 | metrics.increment("octokit.workflow_job"); 56 | 57 | const project = getProjectName({ repoUrl: payload.repository.html_url }); 58 | if (!project) return; 59 | 60 | const repo = payload.repository.html_url.split("/").slice(-2).join("/"); 61 | 62 | console.log(">> Workflow job in", `${repo}:`, payload.workflow_job.name, { 63 | started_at: payload.workflow_job.started_at, 64 | status: payload.workflow_job.status, 65 | conclusion: payload.workflow_job.conclusion, 66 | }); 67 | }); 68 | 69 | webhooks.on("workflow_run", async ({ payload }) => { 70 | metrics.increment("octokit.workflow_run"); 71 | 72 | const project = getProjectName({ repoUrl: payload.repository.html_url }); 73 | if (!project) return; 74 | 75 | const repo = payload.repository.html_url.split("/").slice(-2).join("/"); 76 | 77 | console.log(">> Workflow run in", `${repo}:`, payload.workflow_run.name, { 78 | started_at: payload.workflow_run.run_started_at, 79 | status: payload.workflow_run.status, 80 | conclusion: payload.workflow_run.conclusion, 81 | }); 82 | }); 83 | 84 | webhooks.on("check_run", async ({ payload }) => { 85 | metrics.increment("octokit.check_run"); 86 | 87 | const project = getProjectName({ repoUrl: payload.repository.html_url }); 88 | if (!project) return; 89 | 90 | const repo = payload.repository.html_url.split("/").slice(-2).join("/"); 91 | 92 | console.log(">> Check run in", `${repo}:`, payload.check_run.name, { 93 | started_at: payload.check_run.started_at, 94 | status: payload.check_run.status, 95 | conclusion: payload.check_run.conclusion, 96 | }); 97 | }); 98 | 99 | webhooks.on("check_suite", async ({ payload }) => { 100 | metrics.increment("octokit.check_suite"); 101 | 102 | const project = getProjectName({ repoUrl: payload.repository.html_url }); 103 | if (!project) return; 104 | 105 | const repo = payload.repository.html_url.split("/").slice(-2).join("/"); 106 | 107 | console.log(">> Check run in", `${repo}:`, payload.check_suite.app.name, { 108 | status: payload.check_suite.status, 109 | conclusion: payload.check_suite.conclusion, 110 | }); 111 | }); 112 | 113 | export const createGithubItem = async (payload) => { 114 | metrics.increment("octokit.create.item"); 115 | console.log("🧶🧶 Running github webhook 🧶🧶"); 116 | const { issue, pull_request, repository } = payload; 117 | const item = issue || pull_request; 118 | 119 | const project = getProjectName({ repoUrl: repository.html_url }); 120 | if (!project) return; 121 | 122 | const dbRepo = await prisma.repository.upsert({ 123 | where: { url: repository.html_url }, 124 | create: { name: repository.name, owner: repository.owner.login, url: repository.html_url }, 125 | update: { name: repository.name, owner: repository.owner.login }, 126 | }); 127 | 128 | const maintainers = getMaintainers({ repoUrl: repository.html_url }); 129 | if (maintainers.find((maintainer) => maintainer?.github === item.user.login)) return; 130 | 131 | // find user by login 132 | const user = await prisma.user.findFirst({ where: { githubUsername: item.user.login } }); 133 | let author: User; 134 | 135 | if (!user) author = await prisma.user.create({ data: { githubUsername: item.user.login } }); 136 | else author = user; 137 | 138 | const actionItem = await prisma.actionItem.findFirst({ 139 | where: { githubItems: { some: { nodeId: item.node_id } } }, 140 | }); 141 | 142 | const githubItem = await prisma.githubItem.upsert({ 143 | where: { nodeId: item.node_id }, 144 | create: { 145 | author: { connect: { id: author.id } }, 146 | repository: { connect: { id: dbRepo.id } }, 147 | nodeId: item.node_id, 148 | title: item.title, 149 | body: item.body || "", 150 | number: item.number, 151 | state: "open", 152 | type: item.node_id.startsWith("I_") ? GithubItemType.issue : GithubItemType.pull_request, 153 | createdAt: item.created_at, 154 | updatedAt: item.updated_at, 155 | actionItem: { create: { status: "open", totalReplies: 0 } }, 156 | labelsOnItems: { 157 | create: item.labels?.map(({ name }) => ({ 158 | label: { connectOrCreate: { where: { name }, create: { name } } }, 159 | })), 160 | }, 161 | }, 162 | update: { 163 | state: "open", 164 | title: item.title, 165 | body: item.body || "", 166 | updatedAt: item.updated_at, 167 | labelsOnItems: { 168 | deleteMany: {}, 169 | create: item.labels?.map(({ name }) => ({ 170 | label: { connectOrCreate: { where: { name }, create: { name } } }, 171 | })), 172 | }, 173 | actionItem: { 174 | update: { 175 | status: actionItem?.resolvedAt ? "closed" : "open", 176 | participants: { deleteMany: {} }, 177 | }, 178 | }, 179 | }, 180 | include: { actionItem: true }, 181 | }); 182 | 183 | indexDocument(githubItem.actionItem!.id); 184 | console.log("🧶🧶 GitHub webhook syncing done 🧶🧶"); 185 | }; 186 | 187 | export const getOctokitToken = async (owner: string, repo: string) => { 188 | metrics.increment("octokit.get.token"); 189 | 190 | if (!owner || !repo) return ""; 191 | 192 | const auth = createAppAuth({ 193 | appId, 194 | privateKey, 195 | clientId: process.env.GITHUB_CLIENT_ID, 196 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 197 | }); 198 | 199 | const appAuth = await auth({ type: "app" }); 200 | const octokit = new Octokit(); 201 | 202 | const installation = await octokit.rest.apps.getRepoInstallation({ 203 | owner, 204 | repo, 205 | headers: { authorization: "Bearer " + appAuth.token }, 206 | }); 207 | 208 | const res = await octokit.rest.apps.createInstallationAccessToken({ 209 | installation_id: installation.data.id, 210 | headers: { authorization: "Bearer " + appAuth.token }, 211 | }); 212 | 213 | return res.data.token; 214 | }; 215 | 216 | export const getDisplayName = async ({ 217 | owner, 218 | name, 219 | slackId, 220 | github, 221 | }: { 222 | owner: string; 223 | name: string; 224 | slackId?: string; 225 | github?: string; 226 | }) => { 227 | metrics.increment("octokit.get.display_name"); 228 | 229 | const token = await getOctokitToken(owner, name); 230 | const octokit = new Octokit({ auth: "Bearer " + token }); 231 | const maintainer = MAINTAINERS.find( 232 | (maintainer) => maintainer.github === github || maintainer.slack === slackId 233 | ); 234 | 235 | const displayName = maintainer 236 | ? maintainer.id 237 | : slackId 238 | ? await slack.client.users 239 | .info({ user: slackId }) 240 | .then( 241 | (res) => res.user?.name || res.user?.real_name || res.user?.profile?.display_name || "" 242 | ) 243 | : await octokit.rest.users 244 | .getByUsername({ username: github ?? "" }) 245 | .then((res) => res.data.name || ""); 246 | 247 | return displayName; 248 | }; 249 | 250 | export const listGithubItems = async (owner: string, name: string) => { 251 | metrics.increment("octokit.get.list_items"); 252 | const query = ` 253 | query ($owner: String!, $name: String!) { 254 | repository(owner: $owner, name: $name) { 255 | issues(first: 100, states: OPEN) { 256 | nodes { 257 | id 258 | number 259 | title 260 | bodyText 261 | createdAt 262 | updatedAt 263 | author { 264 | login 265 | } 266 | assignees(first: 5) { 267 | nodes { 268 | login 269 | createdAt 270 | } 271 | } 272 | labels(first:10) { 273 | nodes { 274 | name 275 | } 276 | } 277 | participants (first: 100) { 278 | nodes { 279 | login 280 | } 281 | } 282 | comments(first: 100) { 283 | totalCount 284 | nodes { 285 | author { 286 | resourcePath 287 | login 288 | } 289 | createdAt 290 | } 291 | } 292 | timelineItems (itemTypes:ASSIGNED_EVENT, last: 1) { 293 | edges { 294 | node { 295 | ... on AssignedEvent { 296 | createdAt 297 | } 298 | } 299 | } 300 | } 301 | } 302 | } 303 | 304 | pullRequests(first: 100, states: OPEN) { 305 | nodes { 306 | id 307 | number 308 | title 309 | bodyText 310 | createdAt 311 | updatedAt 312 | author { 313 | login 314 | } 315 | assignees(first: 5) { 316 | nodes { 317 | login 318 | createdAt 319 | } 320 | } 321 | labels(first:10) { 322 | nodes { 323 | name 324 | } 325 | } 326 | participants (first: 100) { 327 | nodes { 328 | login 329 | } 330 | } 331 | comments(first: 100) { 332 | nodes { 333 | author { 334 | resourcePath 335 | login 336 | } 337 | createdAt 338 | } 339 | } 340 | timelineItems (itemTypes:ASSIGNED_EVENT, last: 1) { 341 | edges { 342 | node { 343 | ... on AssignedEvent { 344 | createdAt 345 | } 346 | } 347 | } 348 | } 349 | } 350 | } 351 | } 352 | } 353 | `; 354 | 355 | const token = await getOctokitToken(owner, name); 356 | const octokit = new Octokit({ auth: "Bearer " + token }); 357 | 358 | const res = (await octokit.graphql(query, { owner, name })) as GithubData; 359 | 360 | const items = [...res.repository.issues.nodes, ...res.repository.pullRequests.nodes].map((i) => ({ 361 | ...i, 362 | comments: { 363 | totalCount: i.comments.totalCount, 364 | nodes: i.comments.nodes.filter((c) => !c.author.resourcePath.includes("apps")), 365 | }, 366 | })); 367 | 368 | return items; 369 | }; 370 | 371 | export const getGithubItem = async (owner: string, name: string, id: string) => { 372 | metrics.increment("octokit.get.item"); 373 | 374 | const query = ` 375 | query ($id: ID!) { 376 | node(id: $id) { 377 | ... on Issue { 378 | id 379 | number 380 | title 381 | bodyText 382 | closedAt 383 | assignees(first: 5) { 384 | nodes { 385 | login 386 | createdAt 387 | } 388 | } 389 | labels(first:10) { 390 | nodes { 391 | name 392 | } 393 | } 394 | participants(first: 100) { 395 | nodes { 396 | login 397 | } 398 | } 399 | comments(first: 100) { 400 | totalCount 401 | nodes { 402 | author { 403 | resourcePath 404 | login 405 | } 406 | createdAt 407 | } 408 | } 409 | timelineItems (itemTypes:ASSIGNED_EVENT, last: 1) { 410 | edges { 411 | node { 412 | ... on AssignedEvent { 413 | createdAt 414 | } 415 | } 416 | } 417 | } 418 | } 419 | ... on PullRequest { 420 | id 421 | number 422 | title 423 | bodyText 424 | closedAt 425 | assignees(first: 5) { 426 | nodes { 427 | login 428 | createdAt 429 | } 430 | } 431 | labels(first:10) { 432 | nodes { 433 | name 434 | } 435 | } 436 | participants(first: 100) { 437 | nodes { 438 | login 439 | } 440 | } 441 | comments(first: 100) { 442 | totalCount 443 | nodes { 444 | author { 445 | resourcePath 446 | login 447 | } 448 | createdAt 449 | } 450 | } 451 | timelineItems (itemTypes:ASSIGNED_EVENT, last: 1) { 452 | edges { 453 | node { 454 | ... on AssignedEvent { 455 | createdAt 456 | } 457 | } 458 | } 459 | } 460 | } 461 | } 462 | } 463 | `; 464 | 465 | const token = await getOctokitToken(owner, name); 466 | const octokit = new Octokit({ auth: "Bearer " + token }); 467 | const res = (await octokit.graphql(query, { id })) as SingleIssueOrPullData; 468 | 469 | return { 470 | node: { 471 | ...res.node, 472 | comments: { 473 | totalCount: res.node.comments.totalCount, 474 | nodes: res.node.comments.nodes.filter((c) => !c.author.resourcePath.includes("apps")), 475 | }, 476 | }, 477 | }; 478 | }; 479 | 480 | export const assignIssueToVolunteer = async ( 481 | items: (ActionItem & { 482 | slackMessages: (SlackMessage & { channel: Channel | null; author: User | null })[]; 483 | githubItems: (GithubItem & { repository: Repository | null; author: User | null })[]; 484 | assignee: User | null; 485 | })[], 486 | user: User, 487 | client: typeof slack.client, 488 | user_id: string, 489 | channel_id: string 490 | ) => { 491 | if (!user.githubToken || !user.githubUsername) { 492 | return await client.chat.postEphemeral({ 493 | user: user_id, 494 | channel: channel_id, 495 | text: `You need to connect your GitHub account first. Please go to <${process.env.DEPLOY_URL}/auth?id=${user_id}|this link> to connect your GitHub account.`, 496 | }); 497 | } 498 | 499 | const volunteeringAt = await prisma.volunteerDetail.findFirst({ 500 | where: { assignee: { id: user.id } }, 501 | include: { 502 | issue: { select: { repository: { select: { owner: true, name: true } }, number: true } }, 503 | }, 504 | }); 505 | 506 | if (volunteeringAt) { 507 | return await client.chat.postEphemeral({ 508 | user: user_id, 509 | channel: channel_id, 510 | text: 511 | "You can only volunteer for one issue at a time. Please finish your current issue first:\n\nhttps://github.com/" + 512 | volunteeringAt.issue?.repository?.owner + 513 | "/" + 514 | volunteeringAt.issue?.repository?.name + 515 | "/issues/" + 516 | volunteeringAt.issue?.number, 517 | }); 518 | } 519 | 520 | try { 521 | let assignedIssue: (GithubItem & { repository: Repository | null }) | undefined; 522 | for await (const item of items) { 523 | if (item.githubItems.length === 0) continue; 524 | 525 | // * Github items are always singular for now 526 | const octokit = new Octokit({ 527 | auth: 528 | "Bearer " + 529 | (await getOctokitToken( 530 | item.githubItems[0].repository?.owner || "", 531 | item.githubItems[0].repository?.name || "" 532 | )), 533 | }); 534 | 535 | const issue = await octokit.rest.issues.get({ 536 | owner: item.githubItems[0].repository?.owner || "", 537 | repo: item.githubItems[0].repository?.name || "", 538 | issue_number: item.githubItems[0].number, 539 | }); 540 | 541 | if (issue.data.assignees && issue.data.assignees.length > 0) continue; 542 | const userOctokit = new Octokit({ auth: "Bearer " + user.githubToken }); 543 | 544 | await userOctokit.rest.issues.createComment({ 545 | owner: item.githubItems[0].repository?.owner || "", 546 | repo: item.githubItems[0].repository?.name || "", 547 | issue_number: item.githubItems[0].number, 548 | body: `I'm volunteering to work on this issue.`, 549 | headers: { authorization: "Bearer " + user.githubToken }, 550 | }); 551 | 552 | await octokit.rest.issues.addAssignees({ 553 | owner: item.githubItems[0].repository?.owner || "", 554 | repo: item.githubItems[0].repository?.name || "", 555 | issue_number: item.githubItems[0].number, 556 | assignees: [user.githubUsername], 557 | }); 558 | 559 | assignedIssue = item.githubItems[0]; 560 | break; 561 | } 562 | 563 | if (!assignedIssue) { 564 | return await client.chat.postEphemeral({ 565 | user: user_id, 566 | channel: channel_id, 567 | text: "No issues found to assign to you. Please try again later.", 568 | }); 569 | } 570 | 571 | await prisma.volunteerDetail.create({ 572 | data: { 573 | assignee: { connect: { id: user.id } }, 574 | assignedOn: new Date(), 575 | issue: { connect: { id: assignedIssue.id } }, 576 | }, 577 | }); 578 | 579 | await client.chat.postMessage({ 580 | channel: user_id, 581 | text: `You have been assigned to issue #${assignedIssue.number} on <${assignedIssue.repository?.url}|${assignedIssue.repository?.name}>.\n\nhttps://github.com/${assignedIssue.repository?.owner}/${assignedIssue.repository?.name}/issues/${assignedIssue.number}`, 582 | }); 583 | } catch (e) { 584 | console.log(e); 585 | } 586 | }; 587 | --------------------------------------------------------------------------------