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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ private-key.pem
dist/
build/

# Local Claude scratch
.claude/

# SQLite-backed task store and review log
data/

# OS generated files
.DS_Store
.DS_Store?
Expand Down
45 changes: 34 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Branch protection feature
- Issue/PR templates management
- CODEOWNERS file management
- Web dashboard for monitoring
- Advanced error handling
- **Repository Rulesets** support (`src/rulesets.js`). Branch protection now uses
the modern Rulesets API (`POST /repos/{owner}/{repo}/rulesets`) which targets
`~DEFAULT_BRANCH` instead of a specific branch. Eliminates the `repository.created`
race condition on empty repos. Translates the existing `branch_protection.default`
config block into a ruleset payload — no config rewrite required.
- **Issue-driven repo provisioning** (Cut A: Temper as the brain). New
`issues.opened` handler watches a configured controller repo. When an issue
with the `repo-request` label is filed, Temper parses the issue-form body,
validates it, and enqueues a `provision-repo` task that creates the repo
(template-based when requested), applies full configuration, and replies on
the source issue. See `docs/controller-repo-template/` for the issue form
starter.
- **Persistent task scheduler** wired from `registerApp` (was exported but
never imported). The scheduler now uses an installation-token factory so each
tick gets a fresh, installation-scoped Octokit. New task handlers:
`revert-merge-settings`, `reconcile-repo`, `provision-repo`.
- **`docs/controller-repo-template/`** — starter for the controller repo with
an issue-form schema (`new-repo.yml`). Operators fill in pulseengine-specific
fields (license list, custom properties, default CODEOWNERS team).

### Changed
- Improved logging system
- Better error messages
- Enhanced configuration options
- **Empty-repo bootstrap** no longer skips configuration when the default branch
is missing. Rulesets, merge settings, and labels are applied immediately;
branch-scoped work (templates, codeowners, dependabot) is deferred to a
`reconcile-repo` task that fires after the first push.
- **Idempotency and AI review rate limits** moved from in-memory `Map` to
SQLite-backed KV (`src/persistent-kv.js`). Survives PM2 restarts — webhooks
are no longer re-processed and PRs no longer re-reviewed after a deploy.
- **`handleSignedCommitMerge`** uses the persistent task store (with `delayMs`)
for the 1-hour revert instead of `setTimeout`. The revert now survives a
restart, eliminating the audit-violation case where the repo was left in
the wrong merge mode after a process crash.
- `config.yml` gains `rulesets:` and `controller_repo:` sections (both opt-in
via `enabled`).

### Fixed
- Various bug fixes
- Performance improvements
- Memory leak fixes
- 5×2s retry race on `repository.created` for empty repositories.
- AI review rate limits and webhook idempotency no longer reset on restart.

## [1.0.0] - 2026-01-24

Expand Down
122 changes: 119 additions & 3 deletions __tests__/integration/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,92 @@ describe('app', () => {
_setConfigForTesting({});
});

// =========================================================================
// issues.opened — controller repo / issue-driven provisioning
// =========================================================================
describe('issues.opened (controller-repo provisioning)', () => {
function createIssueOpenedContext(overrides = {}) {
const octokit = createMockOctokit();
return {
id: overrides.id ?? 'delivery-issues-1',
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
octokit,
payload: {
issue: {
number: 42,
body:
'### Repository name\n\nnew-svc\n\n' +
'### Visibility\n\nprivate\n\n' +
'### Description\n\nA new service\n',
labels: [{ name: 'repo-request' }],
...(overrides.issue || {})
},
repository: {
full_name: 'pulseengine/repo-requests',
name: 'repo-requests',
owner: { login: 'pulseengine' },
...(overrides.repository || {})
},
...(overrides.payload || {})
}
};
}

it('ignores issues when controller_repo is disabled', async () => {
_setConfigForTesting({});
const { handlers } = setupApp();
const context = createIssueOpenedContext();
await handlers['issues.opened'](context);
expect(context.octokit.issues.createComment).not.toHaveBeenCalled();
});

it('ignores issues filed in a non-controller repo', async () => {
_setConfigForTesting({
controller_repo: { enabled: true, repo: 'pulseengine/repo-requests', label: 'repo-request' }
});
const { handlers } = setupApp();
const context = createIssueOpenedContext({
repository: { full_name: 'pulseengine/some-other-repo', name: 'some-other-repo' }
});
await handlers['issues.opened'](context);
expect(context.octokit.issues.createComment).not.toHaveBeenCalled();
});

it('ignores issues that lack the configured label', async () => {
_setConfigForTesting({
controller_repo: { enabled: true, repo: 'pulseengine/repo-requests', label: 'repo-request' }
});
const { handlers } = setupApp();
const context = createIssueOpenedContext({ issue: { labels: [{ name: 'bug' }] } });
await handlers['issues.opened'](context);
expect(context.octokit.issues.createComment).not.toHaveBeenCalled();
});

it('rejects invalid form bodies with a comment', async () => {
_setConfigForTesting({
controller_repo: { enabled: true, repo: 'pulseengine/repo-requests', label: 'repo-request' }
});
const { handlers } = setupApp();
const context = createIssueOpenedContext({
issue: { body: '### Repository name\n\n' /* missing */, labels: [{ name: 'repo-request' }] }
});
await handlers['issues.opened'](context);
const body = context.octokit.issues.createComment.mock.calls[0][0].body;
expect(body).toMatch(/rejected/i);
});

it('warns when task store is unavailable (instead of silently dropping the request)', async () => {
_setConfigForTesting({
controller_repo: { enabled: true, repo: 'pulseengine/repo-requests', label: 'repo-request' }
});
const { handlers } = setupApp();
const context = createIssueOpenedContext();
await handlers['issues.opened'](context);
const body = context.octokit.issues.createComment.mock.calls[0][0].body;
expect(body).toMatch(/task store offline/i);
});
});

// =========================================================================
// mapLegacyEnvVars
// =========================================================================
Expand Down Expand Up @@ -321,7 +407,9 @@ describe('app', () => {

it('works without getRouter option', () => {
const { app } = setupApp({ skipRouter: true });
expect(app.on).toHaveBeenCalledTimes(5);
// 6 events: repository.created, issues.opened, issue_comment.created,
// pull_request.opened, pull_request.closed, push
expect(app.on).toHaveBeenCalledTimes(6);
expect(app.onError).toHaveBeenCalledTimes(1);
});

Expand Down Expand Up @@ -537,6 +625,25 @@ describe('app', () => {
expect(configureRepository).not.toHaveBeenCalled();
});

it('passes skipBranchScopedWork=true when default branch never appears', async () => {
_setConfigForTesting({ organization: 'pulseengine' });
configureRepository.mockResolvedValue({ success: true, partial: true });
const { handlers } = setupApp();
const context = createRepoCreatedContext();
// Make getBranch always 404 — empty repo
const err404 = Object.assign(new Error('Not Found'), { status: 404 });
context.octokit.repos.getBranch.mockRejectedValue(err404);

await handlers['repository.created'](context);

expect(configureRepository).toHaveBeenCalledWith(
context.octokit,
context.payload.repository,
undefined,
{ enqueueTask: null, skipBranchScopedWork: true }
);
});

it('configures matching org repo and creates success issue', async () => {
_setConfigForTesting({ organization: 'pulseengine' });
configureRepository.mockResolvedValue({ success: true });
Expand All @@ -545,7 +652,10 @@ describe('app', () => {
await handlers['repository.created'](context);

expect(configureRepository).toHaveBeenCalledWith(
context.octokit, context.payload.repository, undefined, { enqueueTask: null }
context.octokit,
context.payload.repository,
undefined,
{ enqueueTask: null, skipBranchScopedWork: false }
);
expect(context.octokit.issues.create).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -1218,7 +1328,13 @@ describe('app', () => {
const context = createIssueCommentContext('/allow-merge-commit');
await handlers['issue_comment.created'](context);

expect(handleSignedCommitMerge).toHaveBeenCalledWith(context.octokit, 'myorg', 'myrepo', 7);
expect(handleSignedCommitMerge).toHaveBeenCalledWith(
context.octokit,
'myorg',
'myrepo',
7,
{ enqueueTask: null }
);
const body = context.octokit.issues.createComment.mock.calls[0][0].body;
expect(body).toContain('Merge commits temporarily allowed.');
expect(body).toContain('merge commit strategy to preserve signed commits');
Expand Down
Loading
Loading