From 14ae73b27b48790f929e3e45f5170dcfbee085ef Mon Sep 17 00:00:00 2001 From: htilly Date: Tue, 27 Jan 2026 22:21:37 +0100 Subject: [PATCH 1/2] 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 --- .github/workflows/feature-request-enhance.yml | 5 + .gitignore | 1 + index.js | 103 +++++++++++--- lib/github-app.js | 126 ++++++++++++++++++ 4 files changed, 215 insertions(+), 20 deletions(-) create mode 100644 lib/github-app.js diff --git a/.github/workflows/feature-request-enhance.yml b/.github/workflows/feature-request-enhance.yml index a3197ab..274c127 100644 --- a/.github/workflows/feature-request-enhance.yml +++ b/.github/workflows/feature-request-enhance.yml @@ -4,6 +4,11 @@ on: issues: types: [opened, labeled] workflow_dispatch: + +# Prevent parallel runs for the same issue +concurrency: + group: enhance-feature-request-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: false inputs: issue_number: description: 'Issue number to process' diff --git a/.gitignore b/.gitignore index 988c088..dc9ace3 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ Thumbs.db config/*.json config/ssl/ ssl/ +keys/ # =================================================================== # Docker diff --git a/index.js b/index.js index 363c86d..8144c73 100644 --- a/index.js +++ b/index.js @@ -47,6 +47,7 @@ const voting = require('./lib/voting'); const musicHelper = require('./lib/music-helper'); const commandHandlers = require('./lib/command-handlers'); const addHandlers = require('./lib/add-handlers'); +const githubApp = require('./lib/github-app'); const gongMessage = fs.readFileSync('templates/messages/gong.txt', 'utf8').split('\n').filter(Boolean); const voteMessage = fs.readFileSync('templates/messages/vote.txt', 'utf8').split('\n').filter(Boolean); const ttsMessage = fs.readFileSync('templates/messages/tts.txt', 'utf8').split('\n').filter(Boolean); @@ -4744,31 +4745,57 @@ async function _featurerequest(input, channel, userName) { const featureDescription = input.slice(1).join(' '); - // Check if githubToken is configured - const githubToken = config.get('githubToken'); - if (!githubToken) { - logger.warn('[FEATUREREQUEST] githubToken not configured'); - _slackMessage( - '❌ *Feature request not configured*\n\n' + - 'To enable this feature, you need a GitHub Personal Access Token:\n\n' + - '1. Go to: https://github.com/settings/tokens\n' + - '2. Click *"Generate new token (classic)"*\n' + - '3. Select scope: `repo` (or `public_repo` for public repos only)\n' + - '4. Set the token via admin command:\n' + - ' `setconfig githubToken ghp_xxxxxxxxxxxx`\n\n' + - '📖 More info: https://github.com/htilly/SlackONOS#configuration', - channel - ); - return; + // Try GitHub App first, fallback to personal access token + let authToken = null; + let authMethod = null; + + try { + const appToken = await githubApp.getGitHubAppToken(); + if (appToken) { + authToken = appToken; + authMethod = 'GitHub App'; + logger.info('[FEATUREREQUEST] Using GitHub App authentication'); + } + } catch (error) { + logger.warn(`[FEATUREREQUEST] GitHub App auth failed: ${error.message}, falling back to personal token`); + } + + // Fallback to personal access token + if (!authToken) { + const githubToken = config.get('githubToken'); + if (!githubToken) { + logger.warn('[FEATUREREQUEST] No GitHub authentication configured'); + _slackMessage( + '❌ *Feature request not configured*\n\n' + + 'To enable this feature, configure either:\n\n' + + '*Option 1: GitHub App (Recommended)*\n' + + '1. Create GitHub App: https://github.com/settings/apps/new\n' + + '2. Set permissions: Issues: Write\n' + + '3. Install on repository\n' + + '4. Configure via admin commands:\n' + + ' `setconfig githubAppId 2741767`\n' + + ' `setconfig githubAppPrivateKey /path/to/private-key.pem`\n' + + ' `setconfig githubAppInstallationId 106479987`\n\n' + + '*Option 2: Personal Access Token*\n' + + '1. Go to: https://github.com/settings/tokens\n' + + '2. Generate new token (classic) with `repo` scope\n' + + '3. `setconfig githubToken ghp_xxxxxxxxxxxx`\n\n' + + '📖 More info: https://github.com/htilly/SlackONOS#configuration', + channel + ); + return; + } + authToken = githubToken; + authMethod = 'Personal Access Token'; } try { - logger.info(`[FEATUREREQUEST] Creating GitHub issue: ${featureDescription}`); + logger.info(`[FEATUREREQUEST] Creating GitHub issue: ${featureDescription} (using ${authMethod})`); // Create GitHub issue with enhancement label const response = await fetch(`https://api.github.com/repos/htilly/SlackONOS/issues`, { method: 'POST', headers: { - 'Authorization': `token ${githubToken}`, + 'Authorization': `Bearer ${authToken}`, 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json' }, @@ -4786,11 +4813,44 @@ async function _featurerequest(input, channel, userName) { } else { const errorText = await response.text(); logger.error(`[FEATUREREQUEST] GitHub API error: ${response.status} - ${errorText}`); + + // Handle specific error cases + if (response.status === 401) { + // Bad credentials - token is invalid or expired + if (authMethod === 'GitHub App') { + _slackMessage( + '❌ *GitHub App authentication failed*\n\n' + + 'The GitHub App configuration is invalid. Please check:\n\n' + + '1. App ID is correct\n' + + '2. Private key file path is correct and readable\n' + + '3. Installation ID is correct\n' + + '4. App is installed on the repository\n\n' + + 'Or use a Personal Access Token as fallback.', + channel + ); + } else { + _slackMessage( + '❌ *GitHub token invalid or expired*\n\n' + + 'The configured GitHub token is not valid. Please:\n\n' + + '1. Go to: https://github.com/settings/tokens\n' + + '2. Generate a new token (classic) with `repo` scope\n' + + '3. Update the token via admin command:\n' + + ' `setconfig githubToken ghp_xxxxxxxxxxxx`\n\n' + + '📖 More info: https://github.com/htilly/SlackONOS#configuration', + channel + ); + } + return; + } + throw new Error(`GitHub API error: ${response.status} - ${errorText}`); } } catch (err) { logger.error(`[FEATUREREQUEST] Failed to create issue: ${err.message}`, err); - _slackMessage(`❌ Failed to create feature request: ${err.message}`, channel); + // Only show generic error if we haven't already handled it above + if (err.message && !err.message.includes('401')) { + _slackMessage(`❌ Failed to create feature request: ${err.message}`, channel); + } } } @@ -4941,7 +5001,10 @@ async function _setconfig(input, channel, userName) { crossfadeEnabled: { type: 'boolean' }, slackAlwaysThread: { type: 'boolean' }, logLevel: { type: 'string', minLen: 4, maxLen: 5, allowed: ['error', 'warn', 'info', 'debug'] }, - githubToken: { type: 'string', minLen: 4, maxLen: 100, sensitive: true } + githubToken: { type: 'string', minLen: 4, maxLen: 100, sensitive: true }, + githubAppId: { type: 'string', minLen: 1, maxLen: 20 }, + githubAppPrivateKey: { type: 'string', minLen: 50, maxLen: 5000, sensitive: true }, + githubAppInstallationId: { type: 'string', minLen: 1, maxLen: 20 } }; // Make config key case-insensitive diff --git a/lib/github-app.js b/lib/github-app.js new file mode 100644 index 0000000..9aa0db0 --- /dev/null +++ b/lib/github-app.js @@ -0,0 +1,126 @@ +/** + * GitHub App Authentication + * Handles JWT generation and installation token management + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// Hardcoded GitHub App credentials +const GITHUB_APP_ID = '2741767'; +const GITHUB_APP_INSTALLATION_ID = '106479987'; +const GITHUB_APP_PRIVATE_KEY_PATH = path.join(__dirname, '..', 'keys', 'github-app-private-key.pem'); + +let cachedToken = null; +let tokenExpiresAt = null; + +/** + * Generate JWT for GitHub App authentication + * @param {string} appId - GitHub App ID + * @param {string} privateKey - Private key content (PEM format) + * @returns {string} JWT token + */ +function generateJWT(appId, privateKey) { + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now - 60, // Issued at: 60 seconds in the past + exp: now + 600, // Expires: 10 minutes in the future + iss: appId // Issuer: App ID + }; + + const header = { + alg: 'RS256', + typ: 'JWT' + }; + + // Encode header and payload (base64url = base64 without padding, with URL-safe chars) + const base64url = (str) => { + return Buffer.from(str) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + }; + + const encodedHeader = base64url(JSON.stringify(header)); + const encodedPayload = base64url(JSON.stringify(payload)); + const signatureInput = `${encodedHeader}.${encodedPayload}`; + + // Sign with private key + const sign = crypto.createSign('RSA-SHA256'); + sign.update(signatureInput); + sign.end(); + const signature = sign.sign(privateKey, 'base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + + return `${encodedHeader}.${encodedPayload}.${signature}`; +} + +/** + * Get installation access token (cached for 1 hour) + * @param {string} appId - GitHub App ID + * @param {string} privateKey - Private key content + * @param {string} installationId - Installation ID + * @returns {Promise} Installation access token + */ +async function getInstallationToken(appId, privateKey, installationId) { + // Return cached token if still valid (with 5 minute buffer) + if (cachedToken && tokenExpiresAt && Date.now() < tokenExpiresAt - 5 * 60 * 1000) { + return cachedToken; + } + + try { + // Generate JWT + const jwt = generateJWT(appId, privateKey); + + // Request installation token + const response = await fetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${jwt}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to get installation token: ${response.status} - ${errorText}`); + } + + const data = await response.json(); + cachedToken = data.token; + // Tokens expire after 1 hour, cache for 55 minutes to be safe + tokenExpiresAt = Date.now() + 55 * 60 * 1000; + + return cachedToken; + } catch (error) { + throw new Error(`GitHub App authentication failed: ${error.message}`); + } +} + +/** + * Get GitHub App access token (either from cache or by generating new one) + * @returns {Promise} Access token or null if not configured + */ +async function getGitHubAppToken() { + // Check if private key file exists + if (!fs.existsSync(GITHUB_APP_PRIVATE_KEY_PATH)) { + return null; + } + + try { + const privateKey = fs.readFileSync(GITHUB_APP_PRIVATE_KEY_PATH, 'utf8'); + return await getInstallationToken(GITHUB_APP_ID, privateKey, GITHUB_APP_INSTALLATION_ID); + } catch (error) { + throw error; + } +} + +module.exports = { + getGitHubAppToken, + generateJWT // Exported for testing +}; From 6de3a9a26ba81aa14b206514ed377d176393aed4 Mon Sep 17 00:00:00 2001 From: htilly Date: Tue, 27 Jan 2026 22:25:49 +0100 Subject: [PATCH 2/2] Fix: Make generate-implementation independent of preprocess to avoid skipped jobs - Remove preprocess from needs to prevent skipping when preprocess fails/is skipped - Always fetch issue data directly in generate-implementation - Ensures implementation plan is generated even if preprocess has issues --- .github/workflows/feature-request-enhance.yml | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/feature-request-enhance.yml b/.github/workflows/feature-request-enhance.yml index 274c127..45a312e 100644 --- a/.github/workflows/feature-request-enhance.yml +++ b/.github/workflows/feature-request-enhance.yml @@ -256,8 +256,12 @@ jobs: generate-implementation: runs-on: ubuntu-latest - needs: [check-label, preprocess] - if: needs.preprocess.outcome == 'success' + needs: [check-label] + # Run if issue has enhancement label and isn't already enhanced + # Don't depend on preprocess to avoid skipping if preprocess fails/is skipped + if: | + needs.check-label.outputs.has_enhancement == 'true' && + needs.check-label.outputs.already_enhanced == 'false' permissions: contents: write pull-requests: write @@ -279,13 +283,31 @@ jobs: working-directory: .github/agent run: npm install + - name: Fetch issue data + id: fetch-issue + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ needs.check-label.outputs.issue_number }} + run: | + ISSUE_JSON=$(curl -s \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${{ github.repository }}/issues/$ISSUE_NUMBER") + TITLE=$(echo "$ISSUE_JSON" | jq -r '.title') + BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""') + { + echo "issue_title<> "$GITHUB_OUTPUT" + - name: Generate implementation with Claude id: generate env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} CLAUDE_MODEL: ${{ secrets.CLAUDE_MODEL }} - ENHANCED_TASK: ${{ needs.preprocess.outputs.enhanced_task }} - TASK: ${{ needs.preprocess.outputs.issue_title }} + ENHANCED_TASK: ${{ steps.fetch-issue.outputs.issue_title }} + TASK: ${{ steps.fetch-issue.outputs.issue_title }} ISSUE_NUMBER: ${{ needs.check-label.outputs.issue_number }} run: | set -e