Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions .github/workflows/feature-request-enhance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -251,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
Expand All @@ -274,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 // ""')
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Fetch issue data step, you parse the issue body into the BODY variable but never write it to $GITHUB_OUTPUT or otherwise use it. If the body is not needed by later steps, consider removing this parsing to simplify the script; if it is intended as context for the implementation generator, expose it as an output or environment variable and consume it in .github/agent/generate-implementation.mjs.

Suggested change
BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""')

Copilot uses AI. Check for mistakes.
{
echo "issue_title<<EOF"
echo "$TITLE"
echo "EOF"
} >> "$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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Thumbs.db
config/*.json
config/ssl/
ssl/
keys/

# ===================================================================
# Docker
Expand Down
103 changes: 83 additions & 20 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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' +
Comment on lines +4775 to +4778
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GitHub App configuration instructions shown to users here (setconfig githubAppId ..., githubAppPrivateKey ..., githubAppInstallationId ...) do not match the current implementation in lib/github-app.js, which ignores these config keys and instead uses hardcoded IDs and a fixed private key path. As written, following these steps will not actually make the GitHub App authentication work. Either update the auth module to consume these config values or adjust the help text to reflect the real configuration mechanism.

Suggested change
'4. Configure via admin commands:\n' +
' `setconfig githubAppId 2741767`\n' +
' `setconfig githubAppPrivateKey /path/to/private-key.pem`\n' +
' `setconfig githubAppInstallationId 106479987`\n\n' +
'4. Configure the GitHub App credentials on the server (see `lib/github-app.js`).\n' +
' Note: GitHub App settings are not currently managed via `setconfig`.\n\n' +

Copilot uses AI. Check for mistakes.
'*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'
},
Expand All @@ -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);
}
}
}

Expand Down Expand Up @@ -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 }
Comment on lines +5004 to +5007
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new githubAppId, githubAppPrivateKey, and githubAppInstallationId keys added to allowedConfigs are never read anywhere in the codebase (the GitHub App auth module uses hardcoded values instead). This makes setconfig appear to support these options without any effect at runtime. To avoid confusing admins, either hook these config values into lib/github-app.js (and any other relevant places) or remove them until the wiring is complete.

Suggested change
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 }
githubToken: { type: 'string', minLen: 4, maxLen: 100, sensitive: true }

Copilot uses AI. Check for mistakes.
};

// Make config key case-insensitive
Expand Down
126 changes: 126 additions & 0 deletions lib/github-app.js
Original file line number Diff line number Diff line change
@@ -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<string>} 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<string|null>} 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);
Comment on lines +10 to +117
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GitHub App auth module currently uses hardcoded GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and a fixed private key path, and getGitHubAppToken() never reads the new githubAppId, githubAppPrivateKey, or githubAppInstallationId configuration keys you introduced in index.js. This means admins cannot actually reconfigure the app via setconfig as your feature request flow suggests. Please wire these values through nconf (e.g., read from config with sensible defaults) and, if you want to support a configurable private key path or inline key content, update getGitHubAppToken() accordingly instead of relying solely on keys/github-app-private-key.pem.

Copilot uses AI. Check for mistakes.
} catch (error) {
throw error;
}
}

module.exports = {
getGitHubAppToken,
generateJWT // Exported for testing
};
Loading