From 80855020f616324a7dab077abefd43ecfaf42e4f Mon Sep 17 00:00:00 2001 From: thomasRalee Date: Wed, 15 Apr 2026 11:57:26 +0800 Subject: [PATCH] feat: linear integration --- actions/fe-staging-notification/LINEAR.md | 173 ++++++++++ actions/fe-staging-notification/README.md | 56 +-- .../__tests__/commits.test.js | 118 +++++++ .../__tests__/linear.test.js | 295 ++++++++++++++++ actions/fe-staging-notification/action.yml | 13 + actions/fe-staging-notification/dist/index.js | 319 +++++++++++++++++- .../fe-staging-notification/src/commits.js | 40 +++ actions/fe-staging-notification/src/index.js | 84 +++++ actions/fe-staging-notification/src/linear.js | 184 ++++++++++ 9 files changed, 1251 insertions(+), 31 deletions(-) create mode 100644 actions/fe-staging-notification/LINEAR.md create mode 100644 actions/fe-staging-notification/__tests__/commits.test.js create mode 100644 actions/fe-staging-notification/__tests__/linear.test.js create mode 100644 actions/fe-staging-notification/src/commits.js create mode 100644 actions/fe-staging-notification/src/linear.js diff --git a/actions/fe-staging-notification/LINEAR.md b/actions/fe-staging-notification/LINEAR.md new file mode 100644 index 0000000..f0f6801 --- /dev/null +++ b/actions/fe-staging-notification/LINEAR.md @@ -0,0 +1,173 @@ +# Linear Integration + +This document describes the Linear ticket integration for the staging notification action. + +## Overview + +When a staging deployment occurs, the action: + +1. Extracts Linear ticket IDs from commit messages and PR titles +2. Looks up each ticket via Linear's GraphQL API +3. Posts a staging deployment comment on each valid ticket +4. Posts a Slack thread reply listing the linked tickets with URLs + +## Ticket ID Format + +Linear tickets are detected using the pattern: `[A-Z]{1,5}-[0-9]{1,5}` + +Examples of supported formats: +- `INJ-142` - Injective team +- `SEC-146` - Security team +- `I-42` - Single letter prefix +- `ILO-796` - Three letter prefix +- `ID-1364`, `IC-930`, `IA-920` - Other team prefixes + +Multiple tickets can appear in a single commit message or PR title. Tickets are deduplicated across all sources. + +## How Ticket Sources Work + +The action reads tickets from multiple sources (in order): + +1. **GitHub event payload** (automatic) + - **Push events**: All commit messages from `commits[].message` + - **PR events**: Pull request title and body + +2. **`commit-messages` input** (manual, for `workflow_dispatch`) + - Newline-separated commit messages passed by the calling workflow + - Use when the event payload has no commits (manual triggers) + +3. **`pr-title` input** (optional) + - Pass `${{ github.event.pull_request.title }}` from the calling workflow + +All sources are combined and deduplicated before processing. + +## Linear API + +### Authentication + +Requires a Linear API key with the following permissions: +- **Read issues** - to look up tickets by identifier +- **Create comments** - to post staging deployment comments + +Generate an API key at: Settings > API > Personal API keys + +Store it as a GitHub Actions secret (e.g., `LINEAR_API_KEY`). + +### GraphQL Queries Used + +**Issue lookup:** +```graphql +query GetIssue($id: String!) { + issue(id: $id) { + id + identifier + title + url + } +} +``` + +**Comment creation:** +```graphql +mutation CreateComment($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { id } + } +} +``` + +### Rate Limiting + +- Linear allows 1500 requests/hour for most plans +- The action caps at 20 tickets per run to stay well within limits +- If more than 20 tickets are found, a warning is logged and only the first 20 are processed + +### Retry Logic + +- 3 retries with 2-second delays for transient network errors +- GraphQL errors (issue not found, auth errors) are not retried +- 30-second timeout per request + +## Comment Format + +The comment posted on each Linear ticket looks like: + +``` +**Staging Deployment** +- **Repo:** injective-fe +- **Branch:** `feat/new-feature` +- **Staging URL:** https://staging.example.com +- **Author:** github-username +``` + +## Slack Thread Notification + +After posting Linear comments, a Slack thread reply is added listing all linked tickets: + +``` +New staging link deployed (mainnet) +Description: Linear tickets linked: + - INJ-142 - Add login page + - SEC-146 - Fix auth vulnerability +Staging URL: https://staging.example.com +Author: github-username +``` + +Each ticket ID is a clickable link to the Linear issue. + +## Configuration + +### Required Secrets + +| Secret | Description | +|--------|-------------| +| `LINEAR_API_KEY` | Linear API key with read issues + create comments permissions | + +### Workflow Example + +```yaml +- uses: ./actions/fe-staging-notification + with: + repo: ${{ inputs.repo }} + network: ${{ inputs.network }} + staging_url: ${{ steps.deploy.outputs.url }} + slack-user-token: ${{ secrets.SLACK_USER_TOKEN }} + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + linear-api-key: ${{ secrets.LINEAR_API_KEY }} + pr-title: ${{ github.event.pull_request.title }} +``` + +### For `workflow_dispatch` Triggers + +Since `workflow_dispatch` events don't include commit data in the payload, pass commit messages explicitly: + +```yaml +- name: Get recent commits + id: commits + run: echo "messages=$(git log --format='%s' -10)" >> $GITHUB_OUTPUT + +- uses: ./actions/fe-staging-notification + with: + repo: ${{ inputs.repo }} + network: ${{ inputs.network }} + staging_url: ${{ steps.deploy.outputs.url }} + slack-user-token: ${{ secrets.SLACK_USER_TOKEN }} + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + linear-api-key: ${{ secrets.LINEAR_API_KEY }} + commit-messages: ${{ steps.commits.outputs.messages }} +``` + +## Backwards Compatibility + +The Linear integration is fully optional. If `linear-api-key` is not provided: +- No Linear API calls are made +- `linear_tickets` and `linear_links` outputs are empty strings +- All existing Slack notification behavior is unchanged + +## Error Handling + +Linear integration failures are non-fatal: +- If a ticket ID doesn't exist in Linear, it's skipped with a log message +- If the Linear API is unreachable, a warning is logged and the action continues +- The Slack notification always completes regardless of Linear status diff --git a/actions/fe-staging-notification/README.md b/actions/fe-staging-notification/README.md index ee44a3f..f3ecb77 100644 --- a/actions/fe-staging-notification/README.md +++ b/actions/fe-staging-notification/README.md @@ -1,16 +1,16 @@ # fe-staging-notification -A GitHub Action that extracts Jira tickets from commits and sends Slack notifications for frontend staging deployments. +A GitHub Action that sends Slack notifications for frontend staging deployments and integrates with Linear for ticket tracking. ## Features -- Extracts Jira tickets (IL-XXXXX) from commit messages +- Extracts Linear tickets (e.g., INJ-142, SEC-146) from commit messages and PR titles +- Posts staging URL as a comment on each Linear ticket - Creates or updates Slack messages per branch - Threads subsequent deployments to existing messages - Replaces staging URL with latest (no accumulation) -- Deduplicates Jira tickets across the entire thread - Non-fatal error handling (won't fail your CI) -- Built-in retry logic for Slack API calls +- Built-in retry logic for Slack and Linear API calls ## Usage @@ -22,6 +22,8 @@ A GitHub Action that extracts Jira tickets from commits and sends Slack notifica staging_url: "https://staging.example.com" slack-user-token: ${{ secrets.SLACK_USER_TOKEN }} slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + linear-api-key: ${{ secrets.LINEAR_API_KEY }} + pr-title: ${{ github.event.pull_request.title }} ``` ## Inputs @@ -36,20 +38,22 @@ A GitHub Action that extracts Jira tickets from commits and sends Slack notifica | `slack-bot-token` | Yes | - | Slack bot token for sending messages | | `staging_url` | Yes | - | URL of the staging deployment | | `slack-channel` | No | "frontend-staging" | Slack channel name | +| `linear-api-key` | No | - | Linear API key for posting comments on tickets | +| `commit-messages` | No | - | Newline-separated commit messages (for workflow_dispatch) | +| `pr-title` | No | - | Pull request title (for extracting Linear tickets) | ## Outputs | Output | Description | |--------|-------------| | `branch_name` | The branch name that was deployed | -| `jira_tickets` | Comma-separated list of Jira tickets found | -| `jira_links` | Formatted Jira links for Slack | | `message_found` | Whether an existing Slack message was found | | `existing_message_ts` | Timestamp of existing Slack message if found | | `existing_channel_id` | Channel ID of existing Slack message if found | -| `existing_jira_tickets` | Jira tickets from existing Slack message | | `channel_name` | Slack channel name used | | `message_ts` | Timestamp of the Slack message | +| `linear_tickets` | Comma-separated list of Linear ticket IDs found | +| `linear_links` | Comma-separated list of Linear ticket URLs | ## Slack Token Requirements @@ -76,12 +80,14 @@ fe-staging-notification/ ├── src/ # Source code │ ├── index.js # Main entry point - orchestrates the action │ ├── git.js # Branch name detection -│ ├── jira.js # Jira ticket extraction from commits +│ ├── commits.js # Commit message extraction from event payload +│ ├── linear.js # Linear ticket extraction and API integration │ └── slack.js # Slack API helpers with retry logic │ ├── __tests__/ # Unit tests (Vitest) │ ├── git.test.js -│ ├── jira.test.js +│ ├── commits.test.js +│ ├── linear.test.js │ └── slack.test.js │ ├── dist/ # Bundled output (auto-generated) @@ -107,33 +113,33 @@ fe-staging-notification/ │ │ │ 1. Get inputs from action.yml │ │ 2. Detect branch name │ -│ 3. Extract Jira tickets from commits │ +│ 3. Extract Linear tickets from commits │ │ 4. Search for existing Slack message │ │ 5. Update existing OR create new message │ │ 6. Set outputs │ └─────────────────────────────────────────────────────────────┘ │ │ │ ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌──────────┐ - │ git.js │ │ jira.js │ │ slack.js │ - └──────────┘ └──────────┘ └──────────┘ + ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐ + │ git.js │ │ commits.js │ │linear.js │ │ slack.js │ + └──────────┘ └────────────┘ └──────────┘ └──────────┘ - getBranchName() extractJiraTickets() slackRequest() - - INPUT_BRANCH - git fetch origin/dev - Retry logic (3x) - - Event file - git log - Rate limiting - - GITHUB_HEAD_REF - Pattern matching - - GITHUB_REF_NAME generateJiraLinks() searchExistingMessage() - - Slack formatting updateMessage() - postMessage() - postThreadReply() - addMessageId() + getBranchName() getCommitMessages() extractLinearTickets() slackRequest() + - INPUT_BRANCH - Push commits - Regex matching - Retry logic (3x) + - Event file - PR title/body lookupIssue() - Rate limiting + - GITHUB_HEAD_REF postIssueComment() + - GITHUB_REF_NAME formatLinearComment() searchExistingMessage() + updateMessage() + postMessage() + postThreadReply() + addMessageId() ``` ### Key Design Decisions 1. **Dual Slack Tokens**: User token for search (API limitation), bot token for posting 2. **30-Day Search Limit**: Prevents finding stale messages from old deployments -3. **Thread-Based Deduplication**: Scans entire thread for Jira tickets, not just main message +3. **Linear Integration**: Posts staging URL as comments on extracted Linear tickets 4. **URL Replacement**: Shows only the latest staging URL (previous fix for URL accumulation) 5. **Non-Fatal Errors**: Logs warnings but never fails the CI pipeline 6. **Retry Logic**: 3 retries with 2-second delays for transient Slack errors @@ -232,7 +238,7 @@ The repository is now configured to use the JS version: After deployment, monitor for: 1. Slack messages appearing correctly -2. Jira tickets being extracted +2. Linear tickets being extracted and commented on 3. Thread replies working 4. No CI failures due to the action @@ -293,7 +299,7 @@ If rollback was needed, investigate: | "Could not determine branch name" | Missing workflow_dispatch input | Ensure workflow has `branch` input or pass it explicitly | | "Slack API error: channel_not_found" | Wrong channel name or bot not in channel | Verify channel name, invite bot to channel | | "Slack API error: invalid_auth" | Bad token | Regenerate Slack token | -| No Jira tickets found | Commits don't have IL-XXXXX pattern | Check commit message format | +| No Linear tickets found | Commits don't have TEAM-NUMBER pattern | Check commit message format (e.g., INJ-142) | | Message not threading | Search didn't find existing message | Message may be >30 days old | ### Debug Mode diff --git a/actions/fe-staging-notification/__tests__/commits.test.js b/actions/fe-staging-notification/__tests__/commits.test.js new file mode 100644 index 0000000..5a3e74c --- /dev/null +++ b/actions/fe-staging-notification/__tests__/commits.test.js @@ -0,0 +1,118 @@ +import { tmpdir } from 'os'; +import { join } from 'path'; +import { unlinkSync, mkdtempSync, writeFileSync } from 'fs'; +import { it, expect, describe, afterEach, beforeEach } from 'vitest'; +import { getCommitMessages } from '../src/commits.js'; + +describe('commits', () => { + describe('getCommitMessages', () => { + const originalEnv = process.env; + let tempDir; + let eventFilePath; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.GITHUB_EVENT_PATH; + tempDir = mkdtempSync(join(tmpdir(), 'commits-test-')); + eventFilePath = join(tempDir, 'event.json'); + }); + + afterEach(() => { + process.env = originalEnv; + try { + unlinkSync(eventFilePath); + } catch (_e) { + // Ignore + } + }); + + it('should return empty array when no event path', () => { + expect(getCommitMessages()).toEqual([]); + }); + + it('should extract commit messages from push event', () => { + const eventData = { + commits: [ + { message: 'feat: add login INJ-142' }, + { message: 'fix: resolve SEC-146 bug' }, + ], + }; + writeFileSync(eventFilePath, JSON.stringify(eventData)); + process.env.GITHUB_EVENT_PATH = eventFilePath; + + expect(getCommitMessages()).toEqual([ + 'feat: add login INJ-142', + 'fix: resolve SEC-146 bug', + ]); + }); + + it('should extract PR title and body from PR event', () => { + const eventData = { + pull_request: { + title: 'feat: new feature INJ-100', + body: 'Closes INJ-101 and INJ-102', + }, + }; + writeFileSync(eventFilePath, JSON.stringify(eventData)); + process.env.GITHUB_EVENT_PATH = eventFilePath; + + expect(getCommitMessages()).toEqual([ + 'feat: new feature INJ-100', + 'Closes INJ-101 and INJ-102', + ]); + }); + + it('should filter out null/empty messages from commits', () => { + const eventData = { + commits: [ + { message: 'feat: something' }, + { message: '' }, + { message: null }, + { message: 'fix: another' }, + ], + }; + writeFileSync(eventFilePath, JSON.stringify(eventData)); + process.env.GITHUB_EVENT_PATH = eventFilePath; + + expect(getCommitMessages()).toEqual([ + 'feat: something', + 'fix: another', + ]); + }); + + it('should handle PR event with no body', () => { + const eventData = { + pull_request: { + title: 'feat: title only', + }, + }; + writeFileSync(eventFilePath, JSON.stringify(eventData)); + process.env.GITHUB_EVENT_PATH = eventFilePath; + + expect(getCommitMessages()).toEqual(['feat: title only']); + }); + + it('should return empty array for malformed JSON', () => { + writeFileSync(eventFilePath, 'not json'); + process.env.GITHUB_EVENT_PATH = eventFilePath; + + expect(getCommitMessages()).toEqual([]); + }); + + it('should return empty array for empty commits', () => { + const eventData = { commits: [] }; + writeFileSync(eventFilePath, JSON.stringify(eventData)); + process.env.GITHUB_EVENT_PATH = eventFilePath; + + expect(getCommitMessages()).toEqual([]); + }); + + it('should return empty array for unrecognized event type', () => { + const eventData = { action: 'labeled' }; + writeFileSync(eventFilePath, JSON.stringify(eventData)); + process.env.GITHUB_EVENT_PATH = eventFilePath; + + expect(getCommitMessages()).toEqual([]); + }); + }); +}); diff --git a/actions/fe-staging-notification/__tests__/linear.test.js b/actions/fe-staging-notification/__tests__/linear.test.js new file mode 100644 index 0000000..2b379ce --- /dev/null +++ b/actions/fe-staging-notification/__tests__/linear.test.js @@ -0,0 +1,295 @@ +import { it, vi, expect, describe, afterEach, beforeEach } from 'vitest'; +import { + lookupIssue, + linearRequest, + postIssueComment, + formatLinearComment, + extractLinearTickets, +} from '../src/linear.js'; + +// Mock https module +vi.mock('https', () => ({ + default: { + request: vi.fn(), + }, +})); + +import https from 'https'; + +describe('linear', () => { + describe('extractLinearTickets', () => { + it('should extract a single ticket from one message', () => { + expect(extractLinearTickets(['feat: add login INJ-142'])).toEqual(['INJ-142']); + }); + + it('should extract multiple tickets from one message', () => { + const result = extractLinearTickets(['fix: resolve INJ-142 and SEC-146']); + expect(result).toEqual(['INJ-142', 'SEC-146']); + }); + + it('should deduplicate across multiple messages', () => { + const result = extractLinearTickets([ + 'feat: add login INJ-142', + 'fix: related to INJ-142 and SEC-146', + ]); + expect(result).toEqual(['INJ-142', 'SEC-146']); + }); + + it('should return empty array for no matches', () => { + expect(extractLinearTickets(['no tickets here'])).toEqual([]); + }); + + it('should not match lowercase tickets', () => { + expect(extractLinearTickets(['inj-142 is lowercase'])).toEqual([]); + }); + + it('should handle minimum length ticket I-1', () => { + expect(extractLinearTickets(['ticket I-1 here'])).toEqual(['I-1']); + }); + + it('should handle various team prefixes', () => { + const result = extractLinearTickets([ + 'ID-1364 IC-930 SEC-146 IA-920 I-42 ILO-796', + ]); + expect(result).toEqual(['ID-1364', 'IC-930', 'SEC-146', 'IA-920', 'I-42', 'ILO-796']); + }); + + it('should skip null/empty texts', () => { + expect(extractLinearTickets([null, '', undefined, 'INJ-1'])).toEqual(['INJ-1']); + }); + + it('should handle empty array', () => { + expect(extractLinearTickets([])).toEqual([]); + }); + }); + + describe('formatLinearComment', () => { + it('should generate expected markdown', () => { + const result = formatLinearComment({ + repo: 'injective-fe', + branchName: 'feat/login', + stagingUrl: 'https://staging.example.com', + author: 'testuser', + }); + + expect(result).toContain('**Staging Deployment**'); + expect(result).toContain('injective-fe'); + expect(result).toContain('`feat/login`'); + expect(result).toContain('https://staging.example.com'); + expect(result).toContain('testuser'); + }); + }); + + describe('linearRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function mockLinearResponse(response) { + https.request.mockImplementation((_options, callback) => { + const res = { + on: (event, handler) => { + if (event === 'data') {handler(JSON.stringify(response));} + if (event === 'end') {handler();} + }, + }; + callback(res); + + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + setTimeout: vi.fn(), + }; + }); + } + + it('should make successful request', async () => { + const mockData = { data: { issue: { id: '123', title: 'Test' } } }; + mockLinearResponse(mockData); + + const result = await linearRequest('query { viewer { id } }', {}, 'api-key'); + expect(result).toEqual(mockData.data); + }); + + it('should throw on GraphQL errors without retrying', async () => { + mockLinearResponse({ + errors: [{ message: 'Issue not found' }], + }); + + await expect( + linearRequest('query { issue(id: "X-1") { id } }', {}, 'api-key') + ).rejects.toThrow('Linear GraphQL error: Issue not found'); + + // Should not retry on GraphQL errors + expect(https.request).toHaveBeenCalledTimes(1); + }); + + it('should not retry on auth errors', async () => { + mockLinearResponse({ + errors: [{ message: 'Unauthorized' }], + }); + + await expect( + linearRequest('query { viewer { id } }', {}, 'bad-key') + ).rejects.toThrow(); + + expect(https.request).toHaveBeenCalledTimes(1); + }); + + it('should retry on network errors and succeed', async () => { + let attempt = 0; + + https.request.mockImplementation((_options, callback) => { + attempt++; + + if (attempt < 2) { + return { + on: (event, handler) => { + if (event === 'error') {handler(new Error('ECONNRESET'));} + }, + write: vi.fn(), + end: vi.fn(), + setTimeout: vi.fn(), + }; + } + + const res = { + on: (event, handler) => { + if (event === 'data') {handler(JSON.stringify({ data: { ok: true } }));} + if (event === 'end') {handler();} + }, + }; + callback(res); + + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + setTimeout: vi.fn(), + }; + }); + + const resultPromise = linearRequest('query { viewer { id } }', {}, 'api-key'); + await vi.advanceTimersByTimeAsync(2000); + const result = await resultPromise; + expect(result).toEqual({ ok: true }); + expect(attempt).toBe(2); + }); + }); + + describe('lookupIssue', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return issue when found', async () => { + const issue = { id: 'uuid-123', identifier: 'INJ-142', title: 'Test', url: 'https://linear.app/team/issue/INJ-142' }; + https.request.mockImplementation((_options, callback) => { + const res = { + on: (event, handler) => { + if (event === 'data') {handler(JSON.stringify({ data: { issue } }));} + if (event === 'end') {handler();} + }, + }; + callback(res); + + return { on: vi.fn(), write: vi.fn(), end: vi.fn(), setTimeout: vi.fn() }; + }); + + const result = await lookupIssue('INJ-142', 'api-key'); + expect(result).toEqual(issue); + }); + + it('should return null when issue not found', async () => { + https.request.mockImplementation((_options, callback) => { + const res = { + on: (event, handler) => { + if (event === 'data') {handler(JSON.stringify({ data: { issue: null } }));} + if (event === 'end') {handler();} + }, + }; + callback(res); + + return { on: vi.fn(), write: vi.fn(), end: vi.fn(), setTimeout: vi.fn() }; + }); + + const result = await lookupIssue('FAKE-999', 'api-key'); + expect(result).toBeNull(); + }); + + it('should return null on error', async () => { + https.request.mockImplementation((_options, callback) => { + const res = { + on: (event, handler) => { + if (event === 'data') {handler(JSON.stringify({ errors: [{ message: 'Unauthorized' }] }));} + if (event === 'end') {handler();} + }, + }; + callback(res); + + return { on: vi.fn(), write: vi.fn(), end: vi.fn(), setTimeout: vi.fn() }; + }); + + const result = await lookupIssue('INJ-1', 'bad-key'); + expect(result).toBeNull(); + }); + }); + + describe('postIssueComment', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return true on success', async () => { + https.request.mockImplementation((_options, callback) => { + const res = { + on: (event, handler) => { + if (event === 'data') {handler(JSON.stringify({ + data: { commentCreate: { success: true, comment: { id: 'c-1' } } }, + }));} + if (event === 'end') {handler();} + }, + }; + callback(res); + + return { on: vi.fn(), write: vi.fn(), end: vi.fn(), setTimeout: vi.fn() }; + }); + + const result = await postIssueComment('uuid-123', 'Test comment', 'api-key'); + expect(result).toBe(true); + }); + + it('should return false on failure', async () => { + https.request.mockImplementation((_options, callback) => { + const res = { + on: (event, handler) => { + if (event === 'data') {handler(JSON.stringify({ errors: [{ message: 'Unauthorized' }] }));} + if (event === 'end') {handler();} + }, + }; + callback(res); + + return { on: vi.fn(), write: vi.fn(), end: vi.fn(), setTimeout: vi.fn() }; + }); + + const result = await postIssueComment('uuid-123', 'Test', 'bad-key'); + expect(result).toBe(false); + }); + }); +}); diff --git a/actions/fe-staging-notification/action.yml b/actions/fe-staging-notification/action.yml index a3d67b4..6eb2292 100644 --- a/actions/fe-staging-notification/action.yml +++ b/actions/fe-staging-notification/action.yml @@ -28,6 +28,15 @@ inputs: description: "Slack channel name for notifications" required: false default: "frontend-staging" + linear-api-key: + description: "Linear API key for posting comments on tickets" + required: false + commit-messages: + description: "Newline-separated commit messages (for workflow_dispatch where event payload has no commits)" + required: false + pr-title: + description: "Pull request title (for extracting Linear tickets)" + required: false outputs: branch_name: @@ -42,6 +51,10 @@ outputs: description: "Slack channel name used" message_ts: description: "Timestamp of the Slack message (either existing or newly created)" + linear_tickets: + description: "Comma-separated list of Linear ticket IDs found" + linear_links: + description: "Comma-separated list of Linear ticket URLs" runs: using: "node20" diff --git a/actions/fe-staging-notification/dist/index.js b/actions/fe-staging-notification/dist/index.js index 7574221..194114a 100644 --- a/actions/fe-staging-notification/dist/index.js +++ b/actions/fe-staging-notification/dist/index.js @@ -27472,15 +27472,243 @@ function getBranchName() { throw new Error('Could not determine branch name'); } +;// CONCATENATED MODULE: ./src/commits.js + + + +/** + * Get commit messages from GitHub event payload + * For push events: returns commits[].message + * For PR events: returns [pull_request.title, pull_request.body] + * Returns empty array on failure + */ +function getCommitMessages() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) { + return []; + } + + try { + const eventData = JSON.parse((0,external_fs_.readFileSync)(eventPath, 'utf8')); + + // Push event - commits array + if (eventData.commits && Array.isArray(eventData.commits)) { + return eventData.commits + .map((c) => c.message) + .filter(Boolean); + } + + // PR event - title and body + if (eventData.pull_request) { + return [ + eventData.pull_request.title, + eventData.pull_request.body, + ].filter(Boolean); + } + + return []; + } catch (_error) { + core.info('Could not read commit messages from event payload'); + + return []; + } +} + // EXTERNAL MODULE: external "https" var external_https_ = __nccwpck_require__(5692); -;// CONCATENATED MODULE: ./src/slack.js +;// CONCATENATED MODULE: ./src/linear.js const MAX_RETRIES = 3; const RETRY_DELAY_MS = 2000; const REQUEST_TIMEOUT_MS = 30000; +const MAX_TICKETS_PER_RUN = 20; + +/** + * Extract Linear ticket IDs from an array of text strings + * Matches patterns like INJ-142, SEC-146, I-42, ILO-796 + */ +function extractLinearTickets(texts) { + const pattern = /\b[A-Z]{1,5}-\d{1,5}\b/g; + const tickets = new Set(); + + for (const text of texts) { + if (!text) {continue;} + const matches = text.match(pattern); + if (matches) { + for (const match of matches) { + tickets.add(match); + } + } + } + + const result = [...tickets]; + + if (result.length > MAX_TICKETS_PER_RUN) { + core.warning( + `Found ${result.length} Linear tickets, capping at ${MAX_TICKETS_PER_RUN} to avoid rate limiting` + ); + + return result.slice(0, MAX_TICKETS_PER_RUN); + } + + return result; +} + +/** + * Make a GraphQL request to Linear API with retry logic + */ +async function linearRequest(query, variables, apiKey) { + let lastError; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const result = await makeLinearRequest(query, variables, apiKey); + + if (result.errors) { + const errorMsg = result.errors.map((e) => e.message).join(', '); + throw new Error(`Linear GraphQL error: ${errorMsg}`); + } + + return result.data; + } catch (error) { + lastError = error; + + // Don't retry GraphQL or auth errors (non-transient) + if (error.message.includes('Linear GraphQL error')) { + throw error; + } + + if (attempt === MAX_RETRIES) { + throw error; + } + + core.info(`Linear API attempt ${attempt} failed: ${error.message}, retrying...`); + await sleep(RETRY_DELAY_MS); + } + } + + throw lastError; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function makeLinearRequest(query, variables, apiKey) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ query, variables }); + + const options = { + hostname: 'api.linear.app', + port: 443, + path: '/graphql', + method: 'POST', + headers: { + Authorization: apiKey, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }; + + const req = external_https_.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (_e) { + reject(new Error('Failed to parse Linear API response')); + } + }); + }); + + req.on('error', reject); + + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(); + reject(new Error('Linear API request timeout')); + }); + + req.write(body); + req.end(); + }); +} + +/** + * Look up a Linear issue by its identifier (e.g., "INJ-142") + * Returns { id, identifier, title, url } or null if not found + */ +async function lookupIssue(ticketId, apiKey) { + try { + const data = await linearRequest( + `query GetIssue($id: String!) { + issue(id: $id) { + id + identifier + title + url + } + }`, + { id: ticketId }, + apiKey + ); + + return data.issue || null; + } catch (error) { + core.warning(`Failed to look up Linear issue ${ticketId}: ${error.message}`); + + return null; + } +} + +/** + * Post a comment on a Linear issue + */ +async function postIssueComment(issueId, body, apiKey) { + try { + const data = await linearRequest( + `mutation CreateComment($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { + id + } + } + }`, + { input: { issueId, body } }, + apiKey + ); + + return data.commentCreate?.success || false; + } catch (error) { + core.warning(`Failed to post comment on Linear issue: ${error.message}`); + + return false; + } +} + +/** + * Format the comment body for a Linear issue + */ +function formatLinearComment({ repo, branchName, stagingUrl, author }) { + return [ + '**Staging Deployment**', + `- **Repo:** ${repo}`, + `- **Branch:** \`${branchName}\``, + `- **Staging URL:** ${stagingUrl}`, + `- **Author:** ${author}`, + ].join('\n'); +} + +;// CONCATENATED MODULE: ./src/slack.js + + + +const slack_MAX_RETRIES = 3; +const slack_RETRY_DELAY_MS = 2000; +const slack_REQUEST_TIMEOUT_MS = 30000; /** * Make an HTTPS request with retry logic @@ -27488,7 +27716,7 @@ const REQUEST_TIMEOUT_MS = 30000; async function slackRequest(method, path, token, body = null) { let lastError; - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + for (let attempt = 1; attempt <= slack_MAX_RETRIES; attempt++) { try { const result = await makeRequest(method, path, token, body); @@ -27512,14 +27740,14 @@ async function slackRequest(method, path, token, body = null) { } lastError = error; - if (attempt === MAX_RETRIES) { + if (attempt === slack_MAX_RETRIES) { throw error; } core.info(`Attempt ${attempt} failed: ${error.message}, retrying...`); } // Wait before retrying - await sleep(RETRY_DELAY_MS); + await slack_sleep(slack_RETRY_DELAY_MS); } throw lastError; @@ -27536,7 +27764,7 @@ function isRetryableError(error) { return retryableErrors.includes(error); } -function sleep(ms) { +function slack_sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -27568,7 +27796,7 @@ function makeRequest(method, path, token, body) { req.on('error', reject); // Add timeout to prevent hanging requests - req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.setTimeout(slack_REQUEST_TIMEOUT_MS, () => { req.destroy(); reject(new Error('Request timeout')); }); @@ -27716,6 +27944,8 @@ async function addMessageId({ botToken, channelId, messageTs, originalText }) { + + async function run() { try { // Get inputs @@ -27728,6 +27958,9 @@ async function run() { slackBotToken: core.getInput('slack-bot-token', { required: true }), stagingUrl: core.getInput('staging_url', { required: true }), slackChannel: core.getInput('slack-channel') || 'frontend-staging', + linearApiKey: core.getInput('linear-api-key'), + commitMessages: core.getInput('commit-messages'), + prTitle: core.getInput('pr-title'), }; // Step 1: Get branch name (from input, fallback to git context) @@ -27807,6 +28040,80 @@ async function run() { core.setOutput('message_ts', messageTs); core.info('Slack notification completed successfully'); + + // Step 5: Linear ticket integration + if (inputs.linearApiKey) { + try { + // Gather text sources for ticket extraction + const eventMessages = getCommitMessages(); + const manualMessages = inputs.commitMessages + ? inputs.commitMessages.split('\n').filter(Boolean) + : []; + const allTexts = [...eventMessages, ...manualMessages, inputs.prTitle].filter(Boolean); + + const ticketIds = extractLinearTickets(allTexts); + + if (ticketIds.length === 0) { + core.info('No Linear tickets found in commits or PR title'); + core.setOutput('linear_tickets', ''); + core.setOutput('linear_links', ''); + } else { + core.info(`Found Linear tickets: ${ticketIds.join(', ')}`); + + // Look up each ticket and post comments + const validIssues = []; + for (const ticketId of ticketIds) { + const issue = await lookupIssue(ticketId, inputs.linearApiKey); + if (issue) { + validIssues.push(issue); + + const commentBody = formatLinearComment({ + repo: inputs.repo, + branchName, + stagingUrl: inputs.stagingUrl, + author: process.env.GITHUB_ACTOR, + }); + + await postIssueComment(issue.id, commentBody, inputs.linearApiKey); + core.info(`Posted staging comment on ${issue.identifier}`); + } else { + core.info(`Linear ticket ${ticketId} not found, skipping`); + } + } + + // Post Slack thread reply with Linear ticket summary + if (validIssues.length > 0 && messageTs) { + const ticketList = validIssues + .map((issue) => `• <${issue.url}|${issue.identifier}> - ${issue.title}`) + .join('\n'); + + // const channelId = existingMessage + // ? existingMessage.channelId + // : undefined; + + await postThreadReply({ + botToken: inputs.slackBotToken, + channel: inputs.slackChannel, + threadTs: messageTs, + network: inputs.network, + description: `Linear tickets linked:\n${ticketList}`, + stagingUrl: inputs.stagingUrl, + author: process.env.GITHUB_ACTOR, + }); + } + + core.setOutput('linear_tickets', validIssues.map((i) => i.identifier).join(',')); + core.setOutput('linear_links', validIssues.map((i) => i.url).join(',')); + } + } catch (linearError) { + core.warning(`Linear integration failed: ${linearError.message}`); + core.setOutput('linear_tickets', ''); + core.setOutput('linear_links', ''); + } + } else { + core.setOutput('linear_tickets', ''); + core.setOutput('linear_links', ''); + } } catch (error) { // Don't fail the action, just log the error core.warning(`Slack notification failed: ${error.message}`); diff --git a/actions/fe-staging-notification/src/commits.js b/actions/fe-staging-notification/src/commits.js new file mode 100644 index 0000000..90ad72a --- /dev/null +++ b/actions/fe-staging-notification/src/commits.js @@ -0,0 +1,40 @@ +import { readFileSync } from 'fs'; +import * as core from '@actions/core'; + +/** + * Get commit messages from GitHub event payload + * For push events: returns commits[].message + * For PR events: returns [pull_request.title, pull_request.body] + * Returns empty array on failure + */ +export function getCommitMessages() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) { + return []; + } + + try { + const eventData = JSON.parse(readFileSync(eventPath, 'utf8')); + + // Push event - commits array + if (eventData.commits && Array.isArray(eventData.commits)) { + return eventData.commits + .map((c) => c.message) + .filter(Boolean); + } + + // PR event - title and body + if (eventData.pull_request) { + return [ + eventData.pull_request.title, + eventData.pull_request.body, + ].filter(Boolean); + } + + return []; + } catch (_error) { + core.info('Could not read commit messages from event payload'); + + return []; + } +} diff --git a/actions/fe-staging-notification/src/index.js b/actions/fe-staging-notification/src/index.js index b513e32..9aec76e 100644 --- a/actions/fe-staging-notification/src/index.js +++ b/actions/fe-staging-notification/src/index.js @@ -1,5 +1,12 @@ import * as core from '@actions/core'; import { getBranchName } from './git.js'; +import { getCommitMessages } from './commits.js'; +import { + lookupIssue, + postIssueComment, + formatLinearComment, + extractLinearTickets, +} from './linear.js'; import { postMessage, addMessageId, @@ -20,6 +27,9 @@ async function run() { slackBotToken: core.getInput('slack-bot-token', { required: true }), stagingUrl: core.getInput('staging_url', { required: true }), slackChannel: core.getInput('slack-channel') || 'frontend-staging', + linearApiKey: core.getInput('linear-api-key'), + commitMessages: core.getInput('commit-messages'), + prTitle: core.getInput('pr-title'), }; // Step 1: Get branch name (from input, fallback to git context) @@ -99,6 +109,80 @@ async function run() { core.setOutput('message_ts', messageTs); core.info('Slack notification completed successfully'); + + // Step 5: Linear ticket integration + if (inputs.linearApiKey) { + try { + // Gather text sources for ticket extraction + const eventMessages = getCommitMessages(); + const manualMessages = inputs.commitMessages + ? inputs.commitMessages.split('\n').filter(Boolean) + : []; + const allTexts = [...eventMessages, ...manualMessages, inputs.prTitle].filter(Boolean); + + const ticketIds = extractLinearTickets(allTexts); + + if (ticketIds.length === 0) { + core.info('No Linear tickets found in commits or PR title'); + core.setOutput('linear_tickets', ''); + core.setOutput('linear_links', ''); + } else { + core.info(`Found Linear tickets: ${ticketIds.join(', ')}`); + + // Look up each ticket and post comments + const validIssues = []; + for (const ticketId of ticketIds) { + const issue = await lookupIssue(ticketId, inputs.linearApiKey); + if (issue) { + validIssues.push(issue); + + const commentBody = formatLinearComment({ + repo: inputs.repo, + branchName, + stagingUrl: inputs.stagingUrl, + author: process.env.GITHUB_ACTOR, + }); + + await postIssueComment(issue.id, commentBody, inputs.linearApiKey); + core.info(`Posted staging comment on ${issue.identifier}`); + } else { + core.info(`Linear ticket ${ticketId} not found, skipping`); + } + } + + // Post Slack thread reply with Linear ticket summary + if (validIssues.length > 0 && messageTs) { + const ticketList = validIssues + .map((issue) => `• <${issue.url}|${issue.identifier}> - ${issue.title}`) + .join('\n'); + + // const channelId = existingMessage + // ? existingMessage.channelId + // : undefined; + + await postThreadReply({ + botToken: inputs.slackBotToken, + channel: inputs.slackChannel, + threadTs: messageTs, + network: inputs.network, + description: `Linear tickets linked:\n${ticketList}`, + stagingUrl: inputs.stagingUrl, + author: process.env.GITHUB_ACTOR, + }); + } + + core.setOutput('linear_tickets', validIssues.map((i) => i.identifier).join(',')); + core.setOutput('linear_links', validIssues.map((i) => i.url).join(',')); + } + } catch (linearError) { + core.warning(`Linear integration failed: ${linearError.message}`); + core.setOutput('linear_tickets', ''); + core.setOutput('linear_links', ''); + } + } else { + core.setOutput('linear_tickets', ''); + core.setOutput('linear_links', ''); + } } catch (error) { // Don't fail the action, just log the error core.warning(`Slack notification failed: ${error.message}`); diff --git a/actions/fe-staging-notification/src/linear.js b/actions/fe-staging-notification/src/linear.js new file mode 100644 index 0000000..cbd943c --- /dev/null +++ b/actions/fe-staging-notification/src/linear.js @@ -0,0 +1,184 @@ +import https from 'https'; +import * as core from '@actions/core'; + +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 2000; +const REQUEST_TIMEOUT_MS = 30000; +const MAX_TICKETS_PER_RUN = 20; + +/** + * Extract Linear ticket IDs from an array of text strings + * Matches patterns like INJ-142, SEC-146, I-42, ILO-796 + */ +export function extractLinearTickets(texts) { + const pattern = /\b[A-Z]{1,5}-\d{1,5}\b/g; + const tickets = new Set(); + + for (const text of texts) { + if (!text) {continue;} + const matches = text.match(pattern); + if (matches) { + for (const match of matches) { + tickets.add(match); + } + } + } + + const result = [...tickets]; + + if (result.length > MAX_TICKETS_PER_RUN) { + core.warning( + `Found ${result.length} Linear tickets, capping at ${MAX_TICKETS_PER_RUN} to avoid rate limiting` + ); + + return result.slice(0, MAX_TICKETS_PER_RUN); + } + + return result; +} + +/** + * Make a GraphQL request to Linear API with retry logic + */ +export async function linearRequest(query, variables, apiKey) { + let lastError; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const result = await makeLinearRequest(query, variables, apiKey); + + if (result.errors) { + const errorMsg = result.errors.map((e) => e.message).join(', '); + throw new Error(`Linear GraphQL error: ${errorMsg}`); + } + + return result.data; + } catch (error) { + lastError = error; + + // Don't retry GraphQL or auth errors (non-transient) + if (error.message.includes('Linear GraphQL error')) { + throw error; + } + + if (attempt === MAX_RETRIES) { + throw error; + } + + core.info(`Linear API attempt ${attempt} failed: ${error.message}, retrying...`); + await sleep(RETRY_DELAY_MS); + } + } + + throw lastError; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function makeLinearRequest(query, variables, apiKey) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ query, variables }); + + const options = { + hostname: 'api.linear.app', + port: 443, + path: '/graphql', + method: 'POST', + headers: { + Authorization: apiKey, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }; + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (_e) { + reject(new Error('Failed to parse Linear API response')); + } + }); + }); + + req.on('error', reject); + + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(); + reject(new Error('Linear API request timeout')); + }); + + req.write(body); + req.end(); + }); +} + +/** + * Look up a Linear issue by its identifier (e.g., "INJ-142") + * Returns { id, identifier, title, url } or null if not found + */ +export async function lookupIssue(ticketId, apiKey) { + try { + const data = await linearRequest( + `query GetIssue($id: String!) { + issue(id: $id) { + id + identifier + title + url + } + }`, + { id: ticketId }, + apiKey + ); + + return data.issue || null; + } catch (error) { + core.warning(`Failed to look up Linear issue ${ticketId}: ${error.message}`); + + return null; + } +} + +/** + * Post a comment on a Linear issue + */ +export async function postIssueComment(issueId, body, apiKey) { + try { + const data = await linearRequest( + `mutation CreateComment($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { + id + } + } + }`, + { input: { issueId, body } }, + apiKey + ); + + return data.commentCreate?.success || false; + } catch (error) { + core.warning(`Failed to post comment on Linear issue: ${error.message}`); + + return false; + } +} + +/** + * Format the comment body for a Linear issue + */ +export function formatLinearComment({ repo, branchName, stagingUrl, author }) { + return [ + '**Staging Deployment**', + `- **Repo:** ${repo}`, + `- **Branch:** \`${branchName}\``, + `- **Staging URL:** ${stagingUrl}`, + `- **Author:** ${author}`, + ].join('\n'); +}