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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/primer-api-bot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/api': minor
---

Add AI-powered Primer bot for Slack. Includes a GitHub Action entry point and an HTTP server that answer questions about Primer React components using an OpenAI-backed LLM.
38 changes: 38 additions & 0 deletions .github/workflows/primer-bot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Primer Bot
on:
repository_dispatch:
types: [primer-bot-question]
workflow_dispatch:
inputs:
question:
type: string
description: 'Question to ask the Primer bot'
required: true

permissions:
contents: read

jobs:
answer:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd

- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
with:
node-version-file: '.nvmrc'
cache: npm

- name: Install dependencies
run: npm ci

- name: Build @primer/react
run: npm run build -w packages/react

- name: Answer question
env:
GITHUB_MODELS_TOKEN: ${{ secrets.MODELS_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
run: npx tsx packages/primer-api/src/action.ts
6,977 changes: 2,503 additions & 4,474 deletions package-lock.json

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions packages/primer-api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Use ONE of these (GitHub Models preferred for GitHub employees):
# GITHUB_MODELS_TOKEN=ghp_...
# OPENAI_API_KEY=sk-...

# Optional: override the LLM model (default: gpt-4o)
# MODEL=gpt-4o

# HTTP server port (default: 3847)
# PORT=3847

# Slack bot token for posting thread replies (required for GitHub Action)
# SLACK_BOT_TOKEN=xoxb-...

# Optional: require API key for the HTTP server
# PRIMER_API_KEY=your-secret-key
140 changes: 140 additions & 0 deletions packages/primer-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# @primer/api

Adds dynamic LLM-powered answers to the #primer Slack channel. When someone reacts to a question with :robot_face:, a GitHub Action fires, retrieves relevant Primer docs, generates an answer via GitHub Models, and posts it back as a thread reply.

Runs alongside the existing Moveworks-based Primer Bot (which handles @mentions with FAQ matching). No hosted endpoint, no API keys to manage.

## How it works

```
User posts question in #primer
→ Someone reacts with :robot_face:
→ Slack Workflow fires
→ Sends repository_dispatch to GitHub Actions
→ Action retrieves Primer component docs from primer.style
→ Sends context + question to GitHub Models (gpt-4o)
→ Posts answer back to Slack via Web API (thread reply)
```

## Setup

### 1. GitHub Actions secrets

Add these secrets to the `primer/react` repo (Settings > Secrets > Actions):

| Secret | Description |
| ----------------- | ----------------------------------------------------------- |
| `MODELS_TOKEN` | GitHub PAT with `models:read` scope (for GitHub Models API) |
| `SLACK_BOT_TOKEN` | Slack bot token with `chat:write` scope |

### 2. Slack app setup

1. Go to [api.slack.com/apps](https://api.slack.com/apps) > your workspace
2. Create a new app (or use an existing one), name it "Primer AI" or similar
3. Under **OAuth & Permissions**, add the `chat:write` bot scope
4. Install to workspace, copy the Bot User OAuth Token, store as `SLACK_BOT_TOKEN` secret
5. Invite the bot to #primer: `/invite @Primer AI`

### 3. Slack Workflow (triggers the Action)

Create a Slack Workflow that:

1. **Triggers** on emoji reaction (:robot_face:) in #primer
2. **Sends a webhook** (HTTP POST) to trigger the GitHub Action:

**URL:** `https://api.github.com/repos/primer/react/dispatches`

**Headers:**

```
Authorization: Bearer <GITHUB_PAT_WITH_REPO_SCOPE>
Accept: application/vnd.github.v3+json
```

**Body:**

```json
{
"event_type": "primer-bot-question",
"client_payload": {
"question": "{{message_text}}",
"channel": "{{channel_id}}",
"thread_ts": "{{message_ts}}",
"user": "{{user}}"
}
}
```

The `{{...}}` placeholders are Slack Workflow variables from the trigger context.

### 4. Manual testing

Test by manually dispatching the workflow in the GitHub UI:

1. Go to Actions > "Primer Bot" > "Run workflow"
2. Enter a question like "How do I use ActionList with sections?"
3. Check the Action logs for the generated answer

## Local development

```bash
# From repo root
npm install
npm run build -w packages/react

# Set env vars
export GITHUB_MODELS_TOKEN=ghp_... # or OPENAI_API_KEY=sk-...

# Run directly (no Slack posting, prints to stdout)
npx tsx packages/primer-api/src/action.ts "How do I use ActionList?"
```

Also works as an HTTP server for local testing:

```bash
npm run dev -w packages/primer-api
curl -X POST http://localhost:3847/ask \
-H 'Content-Type: application/json' \
-d '{"question": "How do I use ActionList?"}'
```

## Environment variables

| Variable | Required | Description |
| --------------------- | ------------ | ------------------------------------------ |
| `GITHUB_MODELS_TOKEN` | Yes\* | GitHub PAT with `models:read` scope |
| `OPENAI_API_KEY` | Yes\* | Alternative: use OpenAI directly |
| `SLACK_BOT_TOKEN` | Actions only | Slack bot token for posting thread replies |
| `MODEL` | No | LLM model name (default: gpt-4o) |
| `PORT` | No | HTTP server port (default: 3847) |
| `PRIMER_API_KEY` | No | API key for the HTTP server endpoint |

\*One of `GITHUB_MODELS_TOKEN` or `OPENAI_API_KEY` is required. GitHub Models is preferred (free for GitHub employees).

## Architecture

```
packages/primer-api/
├── src/
│ ├── action.ts # GitHub Action entry point (dispatch → LLM → Slack)
│ ├── index.ts # HTTP server entry point (for local dev / direct API)
│ ├── knowledge.ts # Retrieves Primer docs from primer.style
│ ├── llm.ts # OpenAI/GitHub Models integration
│ ├── prompts.ts # System prompt for the LLM
│ └── config.ts # Environment variable handling

.github/workflows/
└── primer-bot.yml # GitHub Action workflow (repository_dispatch trigger)
```

## Comparison with existing Primer Bot

| | Moveworks Bot | This (Primer AI) |
| ------------- | ------------------- | ----------------------------- |
| Trigger | @mention | :robot_face: emoji |
| Response type | FAQ match | Dynamic LLM reasoning |
| Data source | Static FAQ markdown | Live primer.style docs |
| Latency | ~1s | ~15-30s (Action cold start) |
| Infra | Moveworks (managed) | GitHub Actions (zero hosting) |

Both coexist in #primer. Moveworks handles quick FAQ-type questions. This handles nuanced questions that need deeper context.
22 changes: 22 additions & 0 deletions packages/primer-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@primer/api",
"description": "HTTP API for Primer design system knowledge, powered by the MCP data layer and OpenAI GPT-4",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@primer/react": "^38.15.0",
"openai": "^4.73.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.9.2"
}
}
125 changes: 125 additions & 0 deletions packages/primer-api/src/action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* GitHub Action entry point for the Primer bot.
*
* Triggered by repository_dispatch with event_type 'primer-bot-question'.
* Reads the question from the payload, generates an answer using the LLM,
* and posts it back to Slack using the Slack Web API (chat.postMessage) with a bot token.
*/
import {loadConfig} from './config'
import {ask} from './llm'

interface DispatchPayload {
question: string
channel: string
thread_ts: string
user: string
}

async function postToSlack(token: string, channel: string, text: string, threadTs: string) {
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
channel,
text,
// eslint-disable-next-line camelcase
thread_ts: threadTs,
// eslint-disable-next-line camelcase
unfurl_links: false,
// eslint-disable-next-line camelcase
unfurl_media: false,
}),
})

if (!response.ok) {
throw new Error(`Slack API failed: ${response.status} ${response.statusText}`)
}

const data = (await response.json()) as {ok: boolean; error?: string}
if (!data.ok) {
throw new Error(`Slack API error: ${data.error}`)
}
}

async function main() {
const eventPath = process.env.GITHUB_EVENT_PATH
if (!eventPath) {
// Running locally without GitHub Actions context
const question = process.argv[2]
if (!question) {
// eslint-disable-next-line no-console
console.error('Usage: tsx action.ts "your question here"')
process.exit(1)
}

const config = loadConfig()
const result = await ask(question, config)
// eslint-disable-next-line no-console
console.log('\n--- Answer ---')
// eslint-disable-next-line no-console
console.log(result.answer)
// eslint-disable-next-line no-console
console.log(`\nModel: ${result.model}`)
// eslint-disable-next-line no-console
console.log(`Components: ${result.componentsMentioned.join(', ') || 'none'}`)
return
}

// Running in GitHub Actions
const {readFileSync} = await import('node:fs')
const event = JSON.parse(readFileSync(eventPath, 'utf-8'))

// Support both repository_dispatch and workflow_dispatch
let question: string
let payload: DispatchPayload | undefined

if (event.client_payload?.question) {
// repository_dispatch from Slack
payload = event.client_payload as DispatchPayload
question = payload.question
// eslint-disable-next-line no-console
console.log(`Question from @${payload.user}: ${question}`)
} else if (event.inputs?.question) {
// workflow_dispatch (manual trigger)
question = event.inputs.question as string
// eslint-disable-next-line no-console
console.log(`Manual question: ${question}`)
} else {
// eslint-disable-next-line no-console
console.error('No question found in dispatch payload or workflow inputs')
process.exit(1)
}

const config = loadConfig()
const result = await ask(question, config)

// Post to Slack if we have a bot token and a channel to reply to
if (config.slackBotToken && payload?.channel && payload.thread_ts) {
await postToSlack(config.slackBotToken, payload.channel, result.answer, payload.thread_ts)
// eslint-disable-next-line no-console
console.log('Posted answer to Slack')
} else {
// eslint-disable-next-line no-console
console.log('\n--- Answer ---')
// eslint-disable-next-line no-console
console.log(result.answer)
}

// eslint-disable-next-line no-console
console.log(`Model: ${result.model}`)
// eslint-disable-next-line no-console
console.log(`Components referenced: ${result.componentsMentioned.join(', ') || 'none'}`)
}

void (async () => {
try {
await main()
} catch (error) {
// eslint-disable-next-line no-console
console.error('Fatal error:', error)
process.exit(1)
}
})()
34 changes: 34 additions & 0 deletions packages/primer-api/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
function getEnv(name: string): string {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}

function getOptionalEnv(name: string): string | undefined {
return process.env[name]
}

export interface Config {
openaiApiKey: string
openaiBaseUrl: string
model: string
port: number
apiKey: string | undefined
slackBotToken: string | undefined
}

export function loadConfig(): Config {
// GitHub Models uses the same OpenAI-compatible API at a different base URL
const useGitHubModels = Boolean(getOptionalEnv('GITHUB_MODELS_TOKEN'))

return {
openaiApiKey: useGitHubModels ? getEnv('GITHUB_MODELS_TOKEN') : getEnv('OPENAI_API_KEY'),
openaiBaseUrl: useGitHubModels ? 'https://models.inference.ai.azure.com' : 'https://api.openai.com/v1',
model: getOptionalEnv('MODEL') ?? (useGitHubModels ? 'gpt-4o' : 'gpt-4o'),
port: parseInt(getOptionalEnv('PORT') ?? '3847', 10),
apiKey: getOptionalEnv('PRIMER_API_KEY'),
slackBotToken: getOptionalEnv('SLACK_BOT_TOKEN'),
}
}
Loading
Loading