Skip to content

Commit 14ae73b

Browse files
committed
feat: Add GitHub App support and fix duplicate workflow runs
- Add GitHub App authentication module (lib/github-app.js) - Support JWT generation and installation token caching - Update featurerequest to use GitHub App with fallback to personal token - Add better error messages for 401 (invalid token) errors - Fix duplicate workflow runs by adding concurrency group - Add config options: githubAppId, githubAppPrivateKey, githubAppInstallationId GitHub App credentials are hardcoded in github-app.js: - App ID: 2741767 - Installation ID: 106479987 - Private key: keys/github-app-private-key.pem
1 parent 961b424 commit 14ae73b

4 files changed

Lines changed: 215 additions & 20 deletions

File tree

.github/workflows/feature-request-enhance.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ on:
44
issues:
55
types: [opened, labeled]
66
workflow_dispatch:
7+
8+
# Prevent parallel runs for the same issue
9+
concurrency:
10+
group: enhance-feature-request-${{ github.event.issue.number || inputs.issue_number }}
11+
cancel-in-progress: false
712
inputs:
813
issue_number:
914
description: 'Issue number to process'

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Thumbs.db
3838
config/*.json
3939
config/ssl/
4040
ssl/
41+
keys/
4142

4243
# ===================================================================
4344
# Docker

index.js

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const voting = require('./lib/voting');
4747
const musicHelper = require('./lib/music-helper');
4848
const commandHandlers = require('./lib/command-handlers');
4949
const addHandlers = require('./lib/add-handlers');
50+
const githubApp = require('./lib/github-app');
5051
const gongMessage = fs.readFileSync('templates/messages/gong.txt', 'utf8').split('\n').filter(Boolean);
5152
const voteMessage = fs.readFileSync('templates/messages/vote.txt', 'utf8').split('\n').filter(Boolean);
5253
const ttsMessage = fs.readFileSync('templates/messages/tts.txt', 'utf8').split('\n').filter(Boolean);
@@ -4744,31 +4745,57 @@ async function _featurerequest(input, channel, userName) {
47444745

47454746
const featureDescription = input.slice(1).join(' ');
47464747

4747-
// Check if githubToken is configured
4748-
const githubToken = config.get('githubToken');
4749-
if (!githubToken) {
4750-
logger.warn('[FEATUREREQUEST] githubToken not configured');
4751-
_slackMessage(
4752-
'❌ *Feature request not configured*\n\n' +
4753-
'To enable this feature, you need a GitHub Personal Access Token:\n\n' +
4754-
'1. Go to: https://github.com/settings/tokens\n' +
4755-
'2. Click *"Generate new token (classic)"*\n' +
4756-
'3. Select scope: `repo` (or `public_repo` for public repos only)\n' +
4757-
'4. Set the token via admin command:\n' +
4758-
' `setconfig githubToken ghp_xxxxxxxxxxxx`\n\n' +
4759-
'📖 More info: https://github.com/htilly/SlackONOS#configuration',
4760-
channel
4761-
);
4762-
return;
4748+
// Try GitHub App first, fallback to personal access token
4749+
let authToken = null;
4750+
let authMethod = null;
4751+
4752+
try {
4753+
const appToken = await githubApp.getGitHubAppToken();
4754+
if (appToken) {
4755+
authToken = appToken;
4756+
authMethod = 'GitHub App';
4757+
logger.info('[FEATUREREQUEST] Using GitHub App authentication');
4758+
}
4759+
} catch (error) {
4760+
logger.warn(`[FEATUREREQUEST] GitHub App auth failed: ${error.message}, falling back to personal token`);
4761+
}
4762+
4763+
// Fallback to personal access token
4764+
if (!authToken) {
4765+
const githubToken = config.get('githubToken');
4766+
if (!githubToken) {
4767+
logger.warn('[FEATUREREQUEST] No GitHub authentication configured');
4768+
_slackMessage(
4769+
'❌ *Feature request not configured*\n\n' +
4770+
'To enable this feature, configure either:\n\n' +
4771+
'*Option 1: GitHub App (Recommended)*\n' +
4772+
'1. Create GitHub App: https://github.com/settings/apps/new\n' +
4773+
'2. Set permissions: Issues: Write\n' +
4774+
'3. Install on repository\n' +
4775+
'4. Configure via admin commands:\n' +
4776+
' `setconfig githubAppId 2741767`\n' +
4777+
' `setconfig githubAppPrivateKey /path/to/private-key.pem`\n' +
4778+
' `setconfig githubAppInstallationId 106479987`\n\n' +
4779+
'*Option 2: Personal Access Token*\n' +
4780+
'1. Go to: https://github.com/settings/tokens\n' +
4781+
'2. Generate new token (classic) with `repo` scope\n' +
4782+
'3. `setconfig githubToken ghp_xxxxxxxxxxxx`\n\n' +
4783+
'📖 More info: https://github.com/htilly/SlackONOS#configuration',
4784+
channel
4785+
);
4786+
return;
4787+
}
4788+
authToken = githubToken;
4789+
authMethod = 'Personal Access Token';
47634790
}
47644791

47654792
try {
4766-
logger.info(`[FEATUREREQUEST] Creating GitHub issue: ${featureDescription}`);
4793+
logger.info(`[FEATUREREQUEST] Creating GitHub issue: ${featureDescription} (using ${authMethod})`);
47674794
// Create GitHub issue with enhancement label
47684795
const response = await fetch(`https://api.github.com/repos/htilly/SlackONOS/issues`, {
47694796
method: 'POST',
47704797
headers: {
4771-
'Authorization': `token ${githubToken}`,
4798+
'Authorization': `Bearer ${authToken}`,
47724799
'Accept': 'application/vnd.github+json',
47734800
'Content-Type': 'application/json'
47744801
},
@@ -4786,11 +4813,44 @@ async function _featurerequest(input, channel, userName) {
47864813
} else {
47874814
const errorText = await response.text();
47884815
logger.error(`[FEATUREREQUEST] GitHub API error: ${response.status} - ${errorText}`);
4816+
4817+
// Handle specific error cases
4818+
if (response.status === 401) {
4819+
// Bad credentials - token is invalid or expired
4820+
if (authMethod === 'GitHub App') {
4821+
_slackMessage(
4822+
'❌ *GitHub App authentication failed*\n\n' +
4823+
'The GitHub App configuration is invalid. Please check:\n\n' +
4824+
'1. App ID is correct\n' +
4825+
'2. Private key file path is correct and readable\n' +
4826+
'3. Installation ID is correct\n' +
4827+
'4. App is installed on the repository\n\n' +
4828+
'Or use a Personal Access Token as fallback.',
4829+
channel
4830+
);
4831+
} else {
4832+
_slackMessage(
4833+
'❌ *GitHub token invalid or expired*\n\n' +
4834+
'The configured GitHub token is not valid. Please:\n\n' +
4835+
'1. Go to: https://github.com/settings/tokens\n' +
4836+
'2. Generate a new token (classic) with `repo` scope\n' +
4837+
'3. Update the token via admin command:\n' +
4838+
' `setconfig githubToken ghp_xxxxxxxxxxxx`\n\n' +
4839+
'📖 More info: https://github.com/htilly/SlackONOS#configuration',
4840+
channel
4841+
);
4842+
}
4843+
return;
4844+
}
4845+
47894846
throw new Error(`GitHub API error: ${response.status} - ${errorText}`);
47904847
}
47914848
} catch (err) {
47924849
logger.error(`[FEATUREREQUEST] Failed to create issue: ${err.message}`, err);
4793-
_slackMessage(`❌ Failed to create feature request: ${err.message}`, channel);
4850+
// Only show generic error if we haven't already handled it above
4851+
if (err.message && !err.message.includes('401')) {
4852+
_slackMessage(`❌ Failed to create feature request: ${err.message}`, channel);
4853+
}
47944854
}
47954855
}
47964856

@@ -4941,7 +5001,10 @@ async function _setconfig(input, channel, userName) {
49415001
crossfadeEnabled: { type: 'boolean' },
49425002
slackAlwaysThread: { type: 'boolean' },
49435003
logLevel: { type: 'string', minLen: 4, maxLen: 5, allowed: ['error', 'warn', 'info', 'debug'] },
4944-
githubToken: { type: 'string', minLen: 4, maxLen: 100, sensitive: true }
5004+
githubToken: { type: 'string', minLen: 4, maxLen: 100, sensitive: true },
5005+
githubAppId: { type: 'string', minLen: 1, maxLen: 20 },
5006+
githubAppPrivateKey: { type: 'string', minLen: 50, maxLen: 5000, sensitive: true },
5007+
githubAppInstallationId: { type: 'string', minLen: 1, maxLen: 20 }
49455008
};
49465009

49475010
// Make config key case-insensitive

lib/github-app.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* GitHub App Authentication
3+
* Handles JWT generation and installation token management
4+
*/
5+
6+
const crypto = require('crypto');
7+
const fs = require('fs');
8+
const path = require('path');
9+
10+
// Hardcoded GitHub App credentials
11+
const GITHUB_APP_ID = '2741767';
12+
const GITHUB_APP_INSTALLATION_ID = '106479987';
13+
const GITHUB_APP_PRIVATE_KEY_PATH = path.join(__dirname, '..', 'keys', 'github-app-private-key.pem');
14+
15+
let cachedToken = null;
16+
let tokenExpiresAt = null;
17+
18+
/**
19+
* Generate JWT for GitHub App authentication
20+
* @param {string} appId - GitHub App ID
21+
* @param {string} privateKey - Private key content (PEM format)
22+
* @returns {string} JWT token
23+
*/
24+
function generateJWT(appId, privateKey) {
25+
const now = Math.floor(Date.now() / 1000);
26+
const payload = {
27+
iat: now - 60, // Issued at: 60 seconds in the past
28+
exp: now + 600, // Expires: 10 minutes in the future
29+
iss: appId // Issuer: App ID
30+
};
31+
32+
const header = {
33+
alg: 'RS256',
34+
typ: 'JWT'
35+
};
36+
37+
// Encode header and payload (base64url = base64 without padding, with URL-safe chars)
38+
const base64url = (str) => {
39+
return Buffer.from(str)
40+
.toString('base64')
41+
.replace(/\+/g, '-')
42+
.replace(/\//g, '_')
43+
.replace(/=/g, '');
44+
};
45+
46+
const encodedHeader = base64url(JSON.stringify(header));
47+
const encodedPayload = base64url(JSON.stringify(payload));
48+
const signatureInput = `${encodedHeader}.${encodedPayload}`;
49+
50+
// Sign with private key
51+
const sign = crypto.createSign('RSA-SHA256');
52+
sign.update(signatureInput);
53+
sign.end();
54+
const signature = sign.sign(privateKey, 'base64')
55+
.replace(/\+/g, '-')
56+
.replace(/\//g, '_')
57+
.replace(/=/g, '');
58+
59+
return `${encodedHeader}.${encodedPayload}.${signature}`;
60+
}
61+
62+
/**
63+
* Get installation access token (cached for 1 hour)
64+
* @param {string} appId - GitHub App ID
65+
* @param {string} privateKey - Private key content
66+
* @param {string} installationId - Installation ID
67+
* @returns {Promise<string>} Installation access token
68+
*/
69+
async function getInstallationToken(appId, privateKey, installationId) {
70+
// Return cached token if still valid (with 5 minute buffer)
71+
if (cachedToken && tokenExpiresAt && Date.now() < tokenExpiresAt - 5 * 60 * 1000) {
72+
return cachedToken;
73+
}
74+
75+
try {
76+
// Generate JWT
77+
const jwt = generateJWT(appId, privateKey);
78+
79+
// Request installation token
80+
const response = await fetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
81+
method: 'POST',
82+
headers: {
83+
'Authorization': `Bearer ${jwt}`,
84+
'Accept': 'application/vnd.github+json',
85+
'X-GitHub-Api-Version': '2022-11-28'
86+
}
87+
});
88+
89+
if (!response.ok) {
90+
const errorText = await response.text();
91+
throw new Error(`Failed to get installation token: ${response.status} - ${errorText}`);
92+
}
93+
94+
const data = await response.json();
95+
cachedToken = data.token;
96+
// Tokens expire after 1 hour, cache for 55 minutes to be safe
97+
tokenExpiresAt = Date.now() + 55 * 60 * 1000;
98+
99+
return cachedToken;
100+
} catch (error) {
101+
throw new Error(`GitHub App authentication failed: ${error.message}`);
102+
}
103+
}
104+
105+
/**
106+
* Get GitHub App access token (either from cache or by generating new one)
107+
* @returns {Promise<string|null>} Access token or null if not configured
108+
*/
109+
async function getGitHubAppToken() {
110+
// Check if private key file exists
111+
if (!fs.existsSync(GITHUB_APP_PRIVATE_KEY_PATH)) {
112+
return null;
113+
}
114+
115+
try {
116+
const privateKey = fs.readFileSync(GITHUB_APP_PRIVATE_KEY_PATH, 'utf8');
117+
return await getInstallationToken(GITHUB_APP_ID, privateKey, GITHUB_APP_INSTALLATION_ID);
118+
} catch (error) {
119+
throw error;
120+
}
121+
}
122+
123+
module.exports = {
124+
getGitHubAppToken,
125+
generateJWT // Exported for testing
126+
};

0 commit comments

Comments
 (0)