Feat: Github integration

This commit is contained in:
Manoj K 2025-08-04 19:30:23 +05:30 committed by Harshith Mullapudi
parent ddc0b0085c
commit 7a890d4e13
14 changed files with 5084 additions and 0 deletions

View File

@ -4,6 +4,8 @@ import { requireUserId } from "~/services/session.server";
import { logger } from "~/services/logger.service";
import { prisma } from "~/db.server";
import { triggerIntegrationWebhook } from "~/trigger/webhooks/integration-webhook-delivery";
import { scheduler } from "~/trigger/integrations/scheduler";
import { schedules } from "@trigger.dev/sdk";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
@ -28,6 +30,10 @@ export async function action({ request }: ActionFunctionArgs) {
},
});
const integrationAccountSettings = updatedAccount.settings as any;
await schedules.del(integrationAccountSettings.scheduleId);
await triggerIntegrationWebhook(
integrationAccountId,
userId,

2
integrations/github/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
bin
node_modules

View File

@ -0,0 +1,22 @@
{
"arrowParens": "always",
"tabWidth": 2,
"useTabs": false,
"semi": true,
"bracketSpacing": true,
"jsxBracketSameLine": false,
"requirePragma": false,
"proseWrap": "preserve",
"singleQuote": true,
"formatOnSave": true,
"trailingComma": "all",
"printWidth": 100,
"overrides": [
{
"files": ".prettierrc",
"options": {
"parser": "json"
}
}
]
}

View File

@ -0,0 +1,98 @@
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const reactPlugin = require('eslint-plugin-react');
const jestPlugin = require('eslint-plugin-jest');
const importPlugin = require('eslint-plugin-import');
const prettierPlugin = require('eslint-plugin-prettier');
const unusedImportsPlugin = require('eslint-plugin-unused-imports');
const jsxA11yPlugin = require('eslint-plugin-jsx-a11y');
module.exports = [
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
react: reactPlugin,
jest: jestPlugin,
import: importPlugin,
prettier: prettierPlugin,
'unused-imports': unusedImportsPlugin,
'jsx-a11y': jsxA11yPlugin,
},
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'jsx-a11y/label-has-associated-control': 'error',
curly: 'warn',
'dot-location': 'warn',
eqeqeq: 'error',
'prettier/prettier': 'warn',
'unused-imports/no-unused-imports': 'warn',
'no-else-return': 'warn',
'no-lonely-if': 'warn',
'no-inner-declarations': 'off',
'no-unused-vars': 'off',
'no-useless-computed-key': 'warn',
'no-useless-return': 'warn',
'no-var': 'warn',
'object-shorthand': ['warn', 'always'],
'prefer-arrow-callback': 'warn',
'prefer-const': 'warn',
'prefer-destructuring': ['warn', { AssignmentExpression: { array: true } }],
'prefer-object-spread': 'warn',
'prefer-template': 'warn',
'spaced-comment': ['warn', 'always', { markers: ['/'] }],
yoda: 'warn',
'import/order': [
'warn',
{
'newlines-between': 'always',
groups: ['type', 'builtin', 'external', 'internal', ['parent', 'sibling'], 'index'],
pathGroupsExcludedImportTypes: ['builtin'],
pathGroups: [],
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'@typescript-eslint/array-type': ['warn', { default: 'array-simple' }],
'@typescript-eslint/ban-ts-comment': [
'warn',
{
'ts-expect-error': 'allow-with-description',
},
],
'@typescript-eslint/consistent-indexed-object-style': ['warn', 'record'],
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
'@typescript-eslint/no-unused-vars': 'warn',
'react/function-component-definition': [
'warn',
{
namedComponents: 'arrow-function',
unnamedComponents: 'arrow-function',
},
],
'react/jsx-boolean-value': 'warn',
'react/jsx-curly-brace-presence': 'warn',
'react/jsx-fragments': 'warn',
'react/jsx-no-useless-fragment': ['warn', { allowExpressions: true }],
'react/self-closing-comp': 'warn',
},
},
{
files: ['scripts/**/*'],
rules: {
'@typescript-eslint/no-var-requires': 'off',
},
},
];

View File

@ -0,0 +1,64 @@
{
"name": "@core/github",
"version": "0.1.0",
"description": "github extension for CORE",
"main": "./bin/index.js",
"module": "./bin/index.mjs",
"type": "module",
"files": [
"github",
"bin"
],
"bin": {
"github": "./bin/index.js"
},
"scripts": {
"build": "rimraf bin && npx tsup",
"lint": "eslint --ext js,ts,tsx backend/ frontend/ --fix",
"prettier": "prettier --config .prettierrc --write .",
"copy:spec": "cp spec.json bin/"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/preset-typescript": "^7.26.0",
"@types/node": "^18.0.20",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-unused-imports": "^2.0.0",
"prettier": "^3.4.2",
"rimraf": "^3.0.2",
"tslib": "^2.8.1",
"typescript": "^4.7.2",
"tsup": "^8.0.1",
"ncc": "0.3.6"
},
"publishConfig": {
"access": "public"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"axios": "^1.7.9",
"commander": "^12.0.0",
"openai": "^4.0.0",
"react-query": "^3.39.3",
"@redplanethq/sdk": "0.1.2"
}
}

4217
integrations/github/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import nodePolyfills from 'rollup-plugin-node-polyfills';
import { terser } from 'rollup-plugin-terser';
import typescript from 'rollup-plugin-typescript2';
export default [
{
input: 'backend/index.ts',
external: ['axios'],
output: [
{
file: 'dist/backend/index.js',
sourcemap: true,
format: 'cjs',
exports: 'named',
preserveModules: false,
},
],
plugins: [
nodePolyfills(),
json(),
resolve({ extensions: ['.js', '.ts'] }),
commonjs({
include: /\/node_modules\//,
}),
typescript({
tsconfig: 'tsconfig.json',
}),
terser(),
],
},
];

View File

@ -0,0 +1,25 @@
{
"name": "GitHub extension",
"key": "github",
"description": "Plan, track, and manage your agile and software development projects in GitHub. Customize your workflow, collaborate, and release great software.",
"icon": "github",
"schedule": {
"frequency": "*/5 * * * *"
},
"auth": {
"OAuth2": {
"token_url": "https://github.com/login/oauth/access_token",
"authorization_url": "https://github.com/login/oauth/authorize",
"scopes": ["user", "public_repo", "repo", "notifications", "gist", "read:org", "repo_hooks"],
"scope_separator": ","
}
},
"mcp": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer ${config:access_token}",
"Content-Type": "application/json"
}
}
}

View File

@ -0,0 +1,37 @@
import { getGithubData } from './utils';
export async function integrationCreate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any,
) {
const { oauthResponse } = data;
const integrationConfiguration = {
refresh_token: oauthResponse.refresh_token,
access_token: oauthResponse.access_token,
};
const user = await getGithubData(
'https://api.github.com/user',
integrationConfiguration.access_token,
);
return [
{
type: 'account',
data: {
settings: {
login: user.login,
username: user.login,
schedule: {
frequency: '*/15 * * * *',
},
},
accountId: user.id.toString(),
config: {
...integrationConfiguration,
mcp: { tokens: { access_token: integrationConfiguration.access_token } },
},
},
},
];
}

View File

@ -0,0 +1,69 @@
import { handleSchedule } from './schedule';
import { integrationCreate } from './account-create';
import {
IntegrationCLI,
IntegrationEventPayload,
IntegrationEventType,
Spec,
} from '@redplanethq/sdk';
export async function run(eventPayload: IntegrationEventPayload) {
switch (eventPayload.event) {
case IntegrationEventType.SETUP:
console.log(eventPayload.eventBody);
return await integrationCreate(eventPayload.eventBody);
case IntegrationEventType.SYNC:
return await handleSchedule(eventPayload.config, eventPayload.state);
default:
return { message: `The event payload type is ${eventPayload.event}` };
}
}
// CLI implementation that extends the base class
class GitHubCLI extends IntegrationCLI {
constructor() {
super('github', '1.0.0');
}
protected async handleEvent(eventPayload: IntegrationEventPayload): Promise<any> {
return await run(eventPayload);
}
protected async getSpec(): Promise<Spec> {
return {
name: 'GitHub extension',
key: 'github',
description:
'Plan, track, and manage your agile and software development projects in GitHub. Customize your workflow, collaborate, and release great software.',
icon: 'github',
auth: {
OAuth2: {
token_url: 'https://github.com/login/oauth/access_token',
authorization_url: 'https://github.com/login/oauth/authorize',
scopes: [
'user',
'public_repo',
'repo',
'notifications',
'gist',
'read:org',
'repo_hooks',
],
scope_separator: ',',
},
},
};
}
}
// Define a main function and invoke it directly.
// This works after bundling to JS and running with `node index.js`.
function main() {
const githubCLI = new GitHubCLI();
githubCLI.parse();
}
main();

View File

@ -0,0 +1,360 @@
import { getUserEvents, getGithubData } from './utils';
interface GitHubActivityCreateParams {
text: string;
sourceURL: string;
}
interface GitHubSettings {
lastSyncTime?: string;
lastUserEventTime?: string;
username?: string;
}
/**
* Creates an activity message based on GitHub data
*/
function createActivityMessage(params: GitHubActivityCreateParams) {
return {
type: 'activity',
data: {
text: params.text,
sourceURL: params.sourceURL,
},
};
}
/**
* Gets default sync time (24 hours ago)
*/
function getDefaultSyncTime(): string {
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
/**
* Fetches user information from GitHub
*/
async function fetchUserInfo(accessToken: string) {
try {
return await getGithubData('https://api.github.com/user', accessToken);
} catch (error) {
console.error('Error fetching GitHub user info:', error);
return null;
}
}
/**
* Processes GitHub notifications into activity messages
*/
async function processNotifications(accessToken: string, lastSyncTime: string): Promise<any[]> {
const activities = [];
const allowedReasons = [
'assign',
'review_requested',
'mention',
'state_change',
'subscribed',
'author',
'approval_requested',
'comment',
'ci_activity',
'invitation',
'member_feature_requested',
'security_alert',
'security_advisory_credit',
'team_mention',
];
let page = 1;
let hasMorePages = true;
while (hasMorePages) {
try {
const notifications = await getGithubData(
`https://api.github.com/notifications?page=${page}&per_page=50&all=true&since=${lastSyncTime}`,
accessToken,
);
if (!notifications || notifications.length === 0) {
hasMorePages = false;
break;
}
if (notifications.length < 50) {
hasMorePages = false;
} else {
page++;
}
for (const notification of notifications) {
try {
if (!allowedReasons.includes(notification.reason)) {
continue;
}
const repository = notification.repository;
const subject = notification.subject;
let title = '';
let sourceURL = '';
// Get the actual GitHub data for the notification
let githubData: any = {};
if (subject.url) {
try {
githubData = await getGithubData(subject.url, accessToken);
} catch (error) {
console.error('Error fetching GitHub data for notification:', error);
continue;
}
}
const url = githubData.html_url || notification.subject.url || '';
sourceURL = url;
const isIssue = subject.type === 'Issue';
const isPullRequest = subject.type === 'PullRequest';
const isComment = notification.reason === 'comment';
switch (notification.reason) {
case 'assign':
title = `${isIssue ? 'Issue' : 'PR'} assigned to you: #${githubData.number} - ${githubData.title}`;
break;
case 'author':
if (isComment) {
title = `New comment on your ${isIssue ? 'issue' : 'PR'} by ${githubData.user?.login}: ${githubData.body}`;
} else {
title = `You created this ${isIssue ? 'issue' : 'PR'}: #${githubData.number} - ${githubData.title}`;
}
break;
case 'comment':
title = `New comment by ${githubData.user?.login} in ${repository.full_name}: ${githubData.body}`;
break;
case 'manual':
title = `You subscribed to: #${githubData.number} - ${githubData.title}`;
break;
case 'mention':
title = `@mentioned by ${githubData.user?.login} in ${repository.full_name}: ${githubData.body}`;
break;
case 'review_requested':
title = `PR review requested in ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
break;
case 'state_change': {
let stateInfo = '';
if (githubData.state) {
stateInfo = `to ${githubData.state}`;
} else if (githubData.merged) {
stateInfo = 'to merged';
} else if (githubData.closed_at) {
stateInfo = 'to closed';
}
title = `State changed ${stateInfo} in ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
break;
}
case 'subscribed':
if (isComment) {
title = `New comment on watched ${isIssue ? 'issue' : 'PR'} in ${repository.full_name} by ${githubData.user?.login}: ${githubData.body}`;
} else if (isPullRequest) {
title = `New PR created in watched repo ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
} else if (isIssue) {
title = `New issue created in watched repo ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
} else {
title = `Update in watched repo ${repository.full_name}: #${githubData.number} - ${githubData.title}`;
}
break;
case 'team_mention':
title = `Your team was mentioned in ${repository.full_name}`;
break;
default:
title = `GitHub notification: ${repository.full_name}`;
break;
}
if (title && sourceURL) {
activities.push(
createActivityMessage({
text: title,
sourceURL: sourceURL,
}),
);
}
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
}
} catch (error) {
// Silently ignore errors to prevent stdout pollution
hasMorePages = false;
}
}
return activities;
}
/**
* Processes user events (PRs, issues, comments) into activity messages
*/
async function processUserEvents(
username: string,
accessToken: string,
lastUserEventTime: string,
): Promise<any[]> {
const activities = [];
let page = 1;
let hasMorePages = true;
console.log('Processing user events');
while (hasMorePages) {
try {
const userEvents = await getUserEvents(username, page, accessToken, lastUserEventTime);
console.log('User events', userEvents);
if (!userEvents || userEvents.length === 0) {
hasMorePages = false;
break;
}
if (userEvents.length < 30) {
hasMorePages = false;
} else {
page++;
}
for (const event of userEvents) {
try {
let title = '';
const sourceURL = event.html_url || '';
switch (event.type) {
case 'pr':
title = `You created PR #${event.number}: ${event.title}`;
break;
case 'issue':
title = `You created issue #${event.number}: ${event.title}`;
break;
case 'pr_comment':
title = `You commented on PR #${event.number}: ${event.title}`;
break;
case 'issue_comment':
title = `You commented on issue #${event.number}: ${event.title}`;
break;
case 'self_assigned_issue':
title = `You assigned yourself to issue #${event.number}: ${event.title}`;
break;
default:
title = `GitHub activity: ${event.title || 'Unknown'}`;
break;
}
if (title && sourceURL) {
activities.push(
createActivityMessage({
text: title,
sourceURL: sourceURL,
}),
);
}
console.log('Activities', activities);
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
}
} catch (error) {
// Silently ignore errors to prevent stdout pollution
hasMorePages = false;
}
}
return activities;
}
export async function handleSchedule(config: any, state: any) {
try {
const integrationConfiguration = config;
// Check if we have a valid access token
if (!integrationConfiguration?.access_token) {
return [];
}
// Get settings or initialize if not present
let settings = (state || {}) as GitHubSettings;
// Default to 24 hours ago if no last sync times
const lastSyncTime = settings.lastSyncTime || getDefaultSyncTime();
const lastUserEventTime = settings.lastUserEventTime || getDefaultSyncTime();
// Fetch user info to get username if not available
let user;
try {
user = await fetchUserInfo(integrationConfiguration.access_token);
} catch (error) {
return [];
}
if (!user) {
return [];
}
// Update username in settings if not present
if (!settings.username && user.login) {
settings.username = user.login;
}
// Collect all messages
const messages = [];
// Process notifications
try {
const notificationActivities = await processNotifications(
integrationConfiguration.access_token,
lastSyncTime,
);
messages.push(...notificationActivities);
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
// Process user events if we have a username
if (settings.username) {
console.log('Processing user events');
try {
const userEventActivities = await processUserEvents(
settings.username,
integrationConfiguration.access_token,
lastUserEventTime,
);
messages.push(...userEventActivities);
} catch (error) {
// Silently ignore errors to prevent stdout pollution
}
}
// Update last sync times
const newSyncTime = new Date().toISOString();
// Add state message for saving settings
messages.push({
type: 'state',
data: {
...settings,
lastSyncTime: newSyncTime,
lastUserEventTime: newSyncTime,
},
});
return messages;
} catch (error) {
return [];
}
}

View File

@ -0,0 +1,97 @@
import axios from 'axios';
export async function getGithubData(url: string, accessToken: string) {
return (
await axios.get(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
})
).data;
}
/**
* Get user events (PRs, issues, comments) and also issues assigned to the user by themselves.
*/
export async function getUserEvents(
username: string,
page: number,
accessToken: string,
since?: string,
) {
try {
const formattedDate = since ? encodeURIComponent(since.split('T')[0]) : '';
// Search for user's PRs, issues, and comments since the last sync
const [
prsResponse,
issuesResponse,
commentsResponse,
// For self-assigned issues, we need to fetch issues assigned to the user and authored by the user
assignedIssuesResponse,
] = await Promise.all([
// Search for PRs created by user
getGithubData(
`https://api.github.com/search/issues?q=author:${username}+type:pr+created:>${formattedDate}&sort=created&order=desc&page=${page}&per_page=10`,
accessToken,
),
// Search for issues created by user
getGithubData(
`https://api.github.com/search/issues?q=author:${username}+type:issue+created:>${formattedDate}&sort=created&order=desc&page=${page}&per_page=10`,
accessToken,
),
// Search for issues/PRs the user commented on
getGithubData(
`https://api.github.com/search/issues?q=commenter:${username}+updated:>${formattedDate}&sort=updated&order=desc&page=${page}&per_page=10`,
accessToken,
),
// Search for issues assigned to the user and authored by the user (self-assigned)
getGithubData(
`https://api.github.com/search/issues?q=assignee:${username}+author:${username}+type:issue+updated:>${formattedDate}&sort=updated&order=desc&page=${page}&per_page=10`,
accessToken,
),
]);
console.log('PRs found:', prsResponse?.items?.length || 0);
console.log('Issues found:', issuesResponse?.items?.length || 0);
console.log('Comments found:', commentsResponse?.items?.length || 0);
console.log('Self-assigned issues found:', assignedIssuesResponse?.items?.length || 0);
// Return simplified results - combine PRs, issues, commented items, and self-assigned issues
const results = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(prsResponse?.items || []).map((item: any) => ({ ...item, type: 'pr' })),
...(issuesResponse?.items || [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((item: any) => !item.pull_request)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((item: any) => ({ ...item, type: 'issue' })),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(commentsResponse?.items || []).map((item: any) => ({
...item,
type: item.pull_request ? 'pr_comment' : 'issue_comment',
})),
// Add self-assigned issues, but only if not already present in issuesResponse
...(assignedIssuesResponse?.items || [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((item: any) => {
// Only include if not already in issuesResponse (by id)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return !(issuesResponse?.items || []).some((issue: any) => issue.id === item.id);
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((item: any) => ({
...item,
type: 'self_assigned_issue',
})),
];
// Sort by created_at descending
results.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
return results;
} catch (error) {
console.error('Error fetching user activity via search:', error);
return [];
}
}

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "frontend",
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"strictNullChecks": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
"types": ["typePatches"]
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from 'tsup';
import { dependencies } from './package.json';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs'], // or esm if you're using that
bundle: true,
target: 'node16',
outDir: 'bin',
splitting: false,
shims: true,
clean: true,
name: 'github',
platform: 'node',
legacyOutput: false,
noExternal: Object.keys(dependencies || {}), // ⬅️ bundle all deps
treeshake: {
preset: 'recommended',
},
});