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
73 changes: 73 additions & 0 deletions __tests__/integration/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,79 @@ describe('app', () => {
_setConfigForTesting({});
});

// =========================================================================
// chatops_repo enforcement — slash commands only honoured in admin repo
// =========================================================================
describe('issue_comment.created chatops_repo gate', () => {
function ctx(repoFullName, body) {
const [o, r] = repoFullName.split('/');
const octokit = createMockOctokit();
return {
id: 'delivery-chatops',
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
octokit,
payload: {
comment: { body },
repository: { full_name: repoFullName, name: r, owner: { login: o } },
sender: { login: 'avrabe' },
issue: { number: 1 }
}
};
}

it('honours commands from the designated chatops repo', async () => {
_setConfigForTesting({
chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' },
allowed_command_users: ['avrabe']
});
const { handlers } = setupApp();
configureRepository.mockResolvedValue({ success: true });

await handlers['issue_comment.created'](ctx('pulseengine/temper-ops', '/configure-repo'));

expect(configureRepository).toHaveBeenCalled();
});

it('silently ignores slash commands from any other repo', async () => {
_setConfigForTesting({
chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' },
allowed_command_users: ['avrabe']
});
const { handlers } = setupApp();

const c = ctx('pulseengine/some-public-repo', '/configure-repo');
await handlers['issue_comment.created'](c);

expect(configureRepository).not.toHaveBeenCalled();
// Critically: no comment posted back. Silence in public repos.
expect(c.octokit.issues.createComment).not.toHaveBeenCalled();
});

it('does not gate non-command comments (only / triggers are gated)', async () => {
_setConfigForTesting({
chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' }
});
const { handlers } = setupApp();

const c = ctx('pulseengine/some-public-repo', 'just chatting, no command');
await handlers['issue_comment.created'](c);
// Plain comments from any repo are still silently dropped (no command
// matched), and no error is raised.
expect(c.octokit.issues.createComment).not.toHaveBeenCalled();
});

it('falls back to honouring all repos when chatops_repo is disabled (legacy default)', async () => {
_setConfigForTesting({
allowed_command_users: ['avrabe']
});
const { handlers } = setupApp();
configureRepository.mockResolvedValue({ success: true });

await handlers['issue_comment.created'](ctx('pulseengine/anywhere', '/configure-repo'));
expect(configureRepository).toHaveBeenCalled();
});
});

// =========================================================================
// issues.opened — controller repo / issue-driven provisioning
// =========================================================================
Expand Down
10 changes: 10 additions & 0 deletions config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ controller_repo:
# Reaction approvers must add to authorise provisioning.
approval_reaction: "+1"

# ChatOps surface restriction. When enabled, ALL slash commands
# (/configure-repo, /sync-all-repos, /review-pr, etc.) are silently ignored
# from any repo other than the designated `repo`. Combined with making
# `repo` private, the entire command-and-control conversation stays out of
# public view. Bot-initiated configuration PRs in public repos are still
# public — this only hides the *triggers*.
chatops_repo:
enabled: false
repo: pulseengine/temper-ops

# Configuration changes (dependabot.yml, templates, CODEOWNERS) are applied via
# pull request rather than direct push. Required because branch protection
# (PR #19) blocks direct commits to default branches.
Expand Down
16 changes: 15 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from 'path';
import fs from 'node:fs';
import { fileURLToPath } from 'url';
import { createDashboardHandler, DEPLOY_SHA } from './dashboard.js';
import { getConfig, getControllerRepoConfig } from './config.js';
import { getConfig, getControllerRepoConfig, getChatopsRepoConfig } from './config.js';
import { getLogger, setLogger } from './logger.js';
import { configureRepository } from './repository.js';
import {
Expand Down Expand Up @@ -310,6 +310,20 @@ function registerApp(app, options = {}) {
const commandBody = comment.body.trim();
const owner = repository.owner.login;
const repo = repository.name;

// ChatOps surface restriction: when enabled, slash commands are only
// honoured in the designated admin repo. Comments from any other repo
// are silently ignored — no log, no reply, no bot footprint in public
// repos. The actual repo configuration changes still happen in their
// target repos; this only restricts where the *triggers* can come from.
const chatopsCfg = getChatopsRepoConfig();
if (chatopsCfg?.enabled && chatopsCfg?.repo && commandBody.startsWith('/')) {
const fullName = `${owner}/${repo}`;
if (fullName !== chatopsCfg.repo) {
if (deliveryId) markProcessed(deliveryId);
return;
}
}
const issueNumber = context.payload.issue.number;
const senderLogin = sender.login;

Expand Down
4 changes: 4 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ export function getControllerRepoConfig() {
return config?.controller_repo || { enabled: false };
}

export function getChatopsRepoConfig() {
return config?.chatops_repo || { enabled: false };
}

export function getRequiredSignaturesFlag(protectionConfig = {}) {
if (typeof protectionConfig.require_signed_commits === 'boolean') {
return protectionConfig.require_signed_commits;
Expand Down
14 changes: 14 additions & 0 deletions src/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,20 @@ export function validateConfig(config) {
}
}

if (config.chatops_repo !== undefined) {
const cr = config.chatops_repo;
if (typeof cr !== 'object' || cr === null) {
errors.push('chatops_repo must be an object');
} else {
if (cr.enabled !== undefined && typeof cr.enabled !== 'boolean') {
errors.push('chatops_repo.enabled must be a boolean');
}
if (cr.enabled && (typeof cr.repo !== 'string' || !cr.repo.includes('/'))) {
errors.push('chatops_repo.repo must be "owner/repo" when enabled');
}
}
}

if (config.rivet_oracle !== undefined) {
const ro = config.rivet_oracle;
if (typeof ro !== 'object' || ro === null) {
Expand Down
Loading