diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ed704a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Type check + run: bunx tsc --noEmit + + - name: Run tests + run: bun test + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check formatting + run: bunx biome check --diagnostic-level=error . diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml deleted file mode 100644 index 146276e..0000000 --- a/.github/workflows/dependabot.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Dependabot Updates - -on: - schedule: - - cron: '0 0 * * 0' # Weekly on Sunday - workflow_dispatch: - -permissions: - contents: read - pull-requests: write - -jobs: - dependabot: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Run Dependabot - uses: dependabot/fetch-metadata@v2 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0a98e48 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish to npm + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun build ./index.ts --compile --outfile repoprotector + + - name: Setup Node.js for npm + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Publish + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..90ccd4e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,249 @@ +# RepoProtector - Agent Guidelines + +## Project Overview + +RepoProtector is a TUI (Terminal User Interface) application for managing GitHub branch protection rules. It uses `@opentui/core` for rendering terminal UI components and the GitHub CLI (`gh`) for API interactions. + +## Build/Package Commands + +```bash +# Install dependencies +bun install + +# Run the application +bun run index.ts + +# Run with local repository detection (skips org selector) +bun run index.ts --local +# or +bun run start:local + +# Type checking +bun run typecheck +# or +bunx tsc --noEmit + +# Run tests +bun run test +# or +bun test + +# Run a single test file +bun test src/utils/templates.test.ts + +# Lint and format check +bunx biome check . + +# Auto-fix lint/format issues +bunx biome check --write . +``` + +## Runtime & Environment + +- **Runtime**: Bun (NOT Node.js) +- **Language**: TypeScript with strict mode enabled +- **Module System**: ES Modules (`"type": "module"`) + +### Bun-Specific Patterns + +- Use `bun ` instead of `node` or `ts-node` +- Use `Bun.spawn()` for subprocess execution (not `child_process`) +- Use `Bun.write()` for file writes (not `fs.writeFile`) +- Use `bun test` for testing (not jest/vitest) +- Bun auto-loads `.env` files - don't use dotenv package + +## Code Style Guidelines + +### Imports + +```typescript +// Type-only imports use 'type' keyword +import type { Organization, Repository } from './types' + +// Regular imports +import { createCliRenderer, BoxRenderable } from '@opentui/core' + +// Group imports: external packages first, then internal modules +import { homedir } from 'os' +import { join } from 'path' +import { mkdir, readdir } from 'fs/promises' +import type { Template } from '../types' +import { theme } from '../theme' +``` + +### Naming Conventions + +| Type | Convention | Example | +|------|------------|---------| +| Variables | camelCase | `selectedRepos`, `focusIndex` | +| Functions | camelCase | `getOrganizations()`, `showRepoSelector()` | +| Types/Interfaces | PascalCase | `BranchProtection`, `AppState` | +| Constants | camelCase | `CONFIG_DIR`, `TEMPLATES_DIR` | +| Component factories | camelCase with 'create' prefix | `createOrgSelector()` | +| File names | PascalCase for components | `ProtectionEditor.ts` | +| Utility files | camelCase | `templates.ts` | + +### TypeScript Patterns + +```typescript +// Explicit return types on exported functions +export async function getOrganizations(): Promise { + // ... +} + +// Use 'const' with type annotation for state objects +const state: { + screen: Screen + org: Organization | null + repos: Repository[] +} = { + screen: 'orgs', + org: null, + repos: [], +} + +// Interface for callback types +export type OrgSelectedCallback = (org: Organization) => void + +// Union types for finite states +type Screen = 'orgs' | 'repos' | 'branches' | 'editor' | 'templates' | 'preview' + +// Extended types for components with methods +export type ProtectionEditorWithMethods = BoxRenderable & { + setProtection: (protection: BranchProtectionInput | null) => void + handleKey: (key: { name: string; shift: boolean; ctrl: boolean }) => void +} +``` + +### Component Factory Pattern + +Components are created using factory functions that return the container with attached methods: + +```typescript +export function createOrgSelector( + renderer: CliRenderer, + onSelect: OrgSelectedCallback +): OrgSelectorResult { + const container = new BoxRenderable(renderer, { + id: 'org-selector', + width: '100%', + height: '100%', + flexDirection: 'column', + backgroundColor: theme.panelBg, + padding: 1, + }) + + // Build UI hierarchy + container.add(title) + container.add(select) + + return { container, select } +} + +// Return extended type with methods using Object.assign +return Object.assign(container, { setProtection, handleKey }) +``` + +### Error Handling + +```typescript +// Standard pattern: try/catch with instanceof check +try { + const repos = await getOrgRepos(state.org.login) + hideLoading() +} catch (err) { + hideLoading() + showError(err instanceof Error ? err.message : 'Failed to load repositories') +} + +// For expected failures, catch and return null/false +try { + const content = await readFile(path, 'utf-8') + return JSON.parse(content) +} catch { + return null +} + +// Subprocess error handling +const exitCode = await proc.exited +if (exitCode !== 0) { + throw new Error(stderr || `Command failed with exit code ${exitCode}`) +} +``` + +### Async Patterns + +```typescript +// Fire-and-forget async (catch errors to prevent unhandled rejections) +Bun.write(logPath, entry).catch(() => {}) + +// Sequential async operations in loops +for (const target of targets) { + try { + await updateBranchProtection(target.owner, target.repo, target.branch, protection) + } catch (error) { + // Handle individual failures + } +} +``` + +### Subprocess Execution (Bun.spawn) + +```typescript +const proc = Bun.spawn(['gh', 'api', endpoint], { + stdout: 'pipe', + stderr: 'pipe', + stdin: body ? 'pipe' : undefined, +}) + +const stdout = await new Response(proc.stdout as ReadableStream).text() +const stderr = await new Response(proc.stderr as ReadableStream).text() +const exitCode = await proc.exited +``` + +## Project Structure + +``` +src/ +├── app.ts # Main application logic, screen management +├── types.ts # TypeScript interfaces and types +├── theme.ts # Color scheme and keybindings +├── api/ +│ └── github.ts # GitHub API interactions via gh CLI +├── components/ +│ ├── OrgSelector.ts +│ ├── RepoSelector.ts +│ ├── BranchSelector.ts +│ ├── ProtectionEditor.ts +│ ├── TemplateManager.ts +│ └── PreviewPane.ts +└── utils/ + └── templates.ts # Template persistence to ~/.config/repoprotector/ +``` + +## Configuration + +- Config directory: `~/.config/repoprotector/` +- Templates stored as JSON in `~/.config/repoprotector/templates/` +- Application logs written to `~/.config/repoprotector/apply.log` + +## Testing + +When adding tests, follow this pattern: + +```typescript +import { test, expect, describe } from "bun:test" + +describe("module name", () => { + test("should do something", () => { + expect(result).toBe(expected) + }) +}) +``` + +## Notes + +- No code comments unless explaining complex logic (code should be self-documenting) +- Use non-null assertion (`!`) only after explicit null checks +- Prefer early returns to reduce nesting +- Keep functions focused - split if exceeding ~50 lines diff --git a/README.md b/README.md index 2c2aa65..8a77e51 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,4 @@ bun run index.ts ``` This project was created using `bun init` in bun v1.3.8. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. + diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..80c166a --- /dev/null +++ b/biome.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "useNodejsImportProtocol": "off", + "noNonNullAssertion": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + } +} diff --git a/bun.lock b/bun.lock index c766d32..bdbd4b0 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@opentui/core": "^0.1.79", }, "devDependencies": { + "@biomejs/biome": "^2.3.15", "@types/bun": "latest", }, "peerDependencies": { @@ -16,6 +17,24 @@ }, }, "packages": { + "@biomejs/biome": ["@biomejs/biome@2.3.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.15", "@biomejs/cli-darwin-x64": "2.3.15", "@biomejs/cli-linux-arm64": "2.3.15", "@biomejs/cli-linux-arm64-musl": "2.3.15", "@biomejs/cli-linux-x64": "2.3.15", "@biomejs/cli-linux-x64-musl": "2.3.15", "@biomejs/cli-win32-arm64": "2.3.15", "@biomejs/cli-win32-x64": "2.3.15" }, "bin": { "biome": "bin/biome" } }, "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.15", "", { "os": "linux", "cpu": "x64" }, "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.15", "", { "os": "linux", "cpu": "x64" }, "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.15", "", { "os": "win32", "cpu": "x64" }, "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A=="], + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], diff --git a/package.json b/package.json index 735f9ba..2e413c8 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,32 @@ { "name": "repoprotector", "version": "0.1.0", - "module": "index.ts", + "description": "TUI for managing GitHub branch protection rules", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/OpenStaticFish/repoprotector" + }, + "keywords": [ + "github", + "branch-protection", + "cli", + "tui", + "devops" + ], "type": "module", - "private": true, "bin": { "repoprotector": "./index.ts" }, "scripts": { "start": "bun run index.ts", - "start:local": "bun run index.ts --local" + "start:local": "bun run index.ts --local", + "test": "bun test", + "typecheck": "tsc --noEmit" }, "devDependencies": { + "@biomejs/biome": "^2.3.15", "@types/bun": "latest" }, "peerDependencies": { @@ -19,5 +34,8 @@ }, "dependencies": { "@opentui/core": "^0.1.79" + }, + "engines": { + "node": ">=20" } } diff --git a/src/api/github.test.ts b/src/api/github.test.ts new file mode 100644 index 0000000..9ee58d3 --- /dev/null +++ b/src/api/github.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from 'bun:test' + +function extractEnabled( + value: T | { enabled: boolean } | null | undefined, +): T | boolean | null { + if (value === null || value === undefined) return null + if (typeof value === 'object' && value !== null && 'enabled' in value) { + return (value as { enabled: boolean }).enabled + } + return value as T +} + +function transformProtectionResponse(raw: Record) { + return { + url: raw.url as string | undefined, + required_pull_request_reviews: raw.required_pull_request_reviews as unknown, + required_status_checks: raw.required_status_checks as unknown, + enforce_admins: extractEnabled(raw.enforce_admins) as boolean, + required_linear_history: extractEnabled( + raw.required_linear_history, + ) as boolean, + allow_force_pushes: extractEnabled(raw.allow_force_pushes) as boolean, + allow_deletions: extractEnabled(raw.allow_deletions) as boolean, + block_creations: extractEnabled(raw.block_creations) as boolean, + required_conversation_resolution: extractEnabled( + raw.required_conversation_resolution, + ) as boolean, + restrictions: raw.restrictions as unknown, + required_signatures: raw.required_signatures + ? { enabled: extractEnabled(raw.required_signatures) as boolean } + : null, + lock_branch: extractEnabled(raw.lock_branch) as boolean | undefined, + } +} + +describe('extractEnabled', () => { + test('returns null for null input', () => { + expect(extractEnabled(null)).toBeNull() + }) + + test('returns null for undefined input', () => { + expect(extractEnabled(undefined)).toBeNull() + }) + + test('extracts boolean from { enabled: true }', () => { + expect(extractEnabled({ enabled: true })).toBe(true) + }) + + test('extracts boolean from { enabled: false }', () => { + expect(extractEnabled({ enabled: false })).toBe(false) + }) + + test('returns primitive boolean as-is', () => { + expect(extractEnabled(true)).toBe(true) + expect(extractEnabled(false)).toBe(false) + }) + + test('returns other values as-is', () => { + expect(extractEnabled('string')).toBe('string') + expect(extractEnabled(42)).toBe(42) + }) +}) + +describe('transformProtectionResponse', () => { + test('transforms GitHub API response format', () => { + const raw = { + url: 'https://api.github.com/repos/owner/repo/branches/main/protection', + enforce_admins: { enabled: true, url: '...' }, + required_linear_history: { enabled: false }, + allow_force_pushes: { enabled: false }, + allow_deletions: { enabled: false }, + block_creations: { enabled: false }, + required_conversation_resolution: { enabled: true }, + required_pull_request_reviews: { + dismiss_stale_reviews: true, + required_approving_review_count: 2, + }, + required_status_checks: null, + restrictions: null, + } + + const result = transformProtectionResponse(raw) + + expect(result.enforce_admins).toBe(true) + expect(result.required_linear_history).toBe(false) + expect(result.allow_force_pushes).toBe(false) + expect(result.allow_deletions).toBe(false) + expect(result.block_creations).toBe(false) + expect(result.required_conversation_resolution).toBe(true) + }) + + test('handles missing optional fields', () => { + const raw = { + enforce_admins: { enabled: false }, + } + + const result = transformProtectionResponse(raw) + + expect(result.enforce_admins).toBe(false) + expect(result.required_pull_request_reviews).toBeUndefined() + expect(result.required_status_checks).toBeUndefined() + }) + + test('handles required_signatures nested object', () => { + const raw = { + required_signatures: { enabled: true, url: '...' }, + } + + const result = transformProtectionResponse(raw) + + expect(result.required_signatures).toEqual({ enabled: true }) + }) +}) + +describe('updateBranchProtection input sanitization', () => { + test('converts undefined to appropriate defaults', () => { + const protection: Record = {} + + const cleanInput: Record = { + required_pull_request_reviews: + protection.required_pull_request_reviews ?? null, + required_status_checks: protection.required_status_checks ?? null, + enforce_admins: protection.enforce_admins ?? false, + required_linear_history: protection.required_linear_history ?? false, + allow_force_pushes: protection.allow_force_pushes ?? false, + allow_deletions: protection.allow_deletions ?? false, + block_creations: protection.block_creations ?? false, + required_conversation_resolution: + protection.required_conversation_resolution ?? true, + restrictions: protection.restrictions ?? null, + } + + expect(cleanInput.required_pull_request_reviews).toBeNull() + expect(cleanInput.required_status_checks).toBeNull() + expect(cleanInput.enforce_admins).toBe(false) + expect(cleanInput.allow_force_pushes).toBe(false) + expect(cleanInput.required_conversation_resolution).toBe(true) + expect(cleanInput.restrictions).toBeNull() + }) + + test('preserves explicit values', () => { + const protection: Record = { + enforce_admins: true, + allow_force_pushes: true, + required_pull_request_reviews: { + required_approving_review_count: 2, + }, + } + + const cleanInput: Record = { + required_pull_request_reviews: + protection.required_pull_request_reviews ?? null, + required_status_checks: protection.required_status_checks ?? null, + enforce_admins: protection.enforce_admins ?? false, + required_linear_history: protection.required_linear_history ?? false, + allow_force_pushes: protection.allow_force_pushes ?? false, + allow_deletions: protection.allow_deletions ?? false, + block_creations: protection.block_creations ?? false, + required_conversation_resolution: + protection.required_conversation_resolution ?? true, + restrictions: protection.restrictions ?? null, + } + + expect(cleanInput.enforce_admins).toBe(true) + expect(cleanInput.allow_force_pushes).toBe(true) + expect(cleanInput.required_pull_request_reviews).toEqual({ + required_approving_review_count: 2, + }) + }) +}) diff --git a/src/api/github.ts b/src/api/github.ts index 987e0a7..1ef065b 100644 --- a/src/api/github.ts +++ b/src/api/github.ts @@ -1,29 +1,29 @@ import type { - Organization, - Repository, + ApplyResult, Branch, BranchProtection, BranchProtectionInput, - ApplyResult, + Organization, + Repository, } from '../types' async function ghApi( endpoint: string, method: 'GET' | 'PUT' | 'DELETE' = 'GET', - body?: object + body?: object, ): Promise { const args = ['api', endpoint] - + if (method !== 'GET') { args.push('-X', method) } - + if (body) { args.push('--input', '-') } - + let proc: Bun.Subprocess - + if (body) { proc = Bun.spawn(['gh', ...args], { stdout: 'pipe', @@ -32,7 +32,10 @@ async function ghApi( }) const bodyStr = JSON.stringify(body) const encoder = new TextEncoder() - const stdin = proc.stdin! as unknown as { write: (data: Uint8Array) => Promise; close: () => void } + const stdin = proc.stdin! as unknown as { + write: (data: Uint8Array) => Promise + close: () => void + } await stdin.write(encoder.encode(bodyStr)) stdin.close() } else { @@ -41,19 +44,19 @@ async function ghApi( stderr: 'pipe', }) } - + const stdout = await new Response(proc.stdout as ReadableStream).text() const stderr = await new Response(proc.stderr as ReadableStream).text() const exitCode = await proc.exited - + if (exitCode !== 0) { throw new Error(stderr || `gh api failed with exit code ${exitCode}`) } - + if (!stdout.trim()) { return null } - + return JSON.parse(stdout) } @@ -66,22 +69,67 @@ export async function getOrgRepos(org: string): Promise { } export async function getUserRepos(): Promise { - return (await ghApi('/user/repos?per_page=100&affiliation=owner')) as Repository[] + return (await ghApi( + '/user/repos?per_page=100&affiliation=owner', + )) as Repository[] +} + +export async function getRepoBranches( + owner: string, + repo: string, +): Promise { + return (await ghApi( + `/repos/${owner}/${repo}/branches?per_page=100`, + )) as Branch[] +} + +function extractEnabled( + value: T | { enabled: boolean } | null | undefined, +): T | boolean | null { + if (value === null || value === undefined) return null + if (typeof value === 'object' && value !== null && 'enabled' in value) { + return (value as { enabled: boolean }).enabled + } + return value as T } -export async function getRepoBranches(owner: string, repo: string): Promise { - return (await ghApi(`/repos/${owner}/${repo}/branches?per_page=100`)) as Branch[] +function transformProtectionResponse( + raw: Record, +): BranchProtection { + return { + url: raw.url as string | undefined, + required_pull_request_reviews: + raw.required_pull_request_reviews as BranchProtection['required_pull_request_reviews'], + required_status_checks: + raw.required_status_checks as BranchProtection['required_status_checks'], + enforce_admins: extractEnabled(raw.enforce_admins) as boolean, + required_linear_history: extractEnabled( + raw.required_linear_history, + ) as boolean, + allow_force_pushes: extractEnabled(raw.allow_force_pushes) as boolean, + allow_deletions: extractEnabled(raw.allow_deletions) as boolean, + block_creations: extractEnabled(raw.block_creations) as boolean, + required_conversation_resolution: extractEnabled( + raw.required_conversation_resolution, + ) as boolean, + restrictions: raw.restrictions as BranchProtection['restrictions'], + required_signatures: raw.required_signatures + ? { enabled: extractEnabled(raw.required_signatures) as boolean } + : null, + lock_branch: extractEnabled(raw.lock_branch) as boolean | undefined, + } } export async function getBranchProtection( owner: string, repo: string, - branch: string + branch: string, ): Promise { try { - return (await ghApi( - `/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}/protection` - )) as BranchProtection + const raw = await ghApi( + `/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}/protection`, + ) + return transformProtectionResponse(raw as Record) } catch (error) { const msg = error instanceof Error ? error.message : '' if (msg.includes('404') || msg.includes('Not Found')) { @@ -95,35 +143,72 @@ export async function updateBranchProtection( owner: string, repo: string, branch: string, - protection: BranchProtectionInput + protection: BranchProtectionInput, ): Promise { + let cleanStatusChecks = null + if (protection.required_status_checks) { + const rsc = protection.required_status_checks + cleanStatusChecks = { + strict: rsc.strict ?? false, + contexts: rsc.contexts ?? [], + } + } + + let cleanPrReviews = null + if (protection.required_pull_request_reviews) { + const rpr = protection.required_pull_request_reviews + cleanPrReviews = { + dismiss_stale_reviews: rpr.dismiss_stale_reviews ?? false, + require_code_owner_reviews: rpr.require_code_owner_reviews ?? false, + required_approving_review_count: rpr.required_approving_review_count ?? 1, + } + } + + const cleanInput: Record = { + required_pull_request_reviews: cleanPrReviews, + required_status_checks: cleanStatusChecks, + enforce_admins: protection.enforce_admins ?? false, + required_linear_history: protection.required_linear_history ?? false, + allow_force_pushes: protection.allow_force_pushes ?? false, + allow_deletions: protection.allow_deletions ?? false, + block_creations: protection.block_creations ?? false, + required_conversation_resolution: + protection.required_conversation_resolution ?? true, + restrictions: protection.restrictions ?? null, + } + return (await ghApi( `/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}/protection`, 'PUT', - protection + cleanInput, )) as BranchProtection } export async function deleteBranchProtection( owner: string, repo: string, - branch: string + branch: string, ): Promise { await ghApi( `/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}/protection`, - 'DELETE' + 'DELETE', ) } export async function applyProtectionToMultiple( targets: { owner: string; repo: string; branch: string }[], - protection: BranchProtectionInput + protection: BranchProtectionInput, ): Promise { const results: ApplyResult[] = [] - + for (const target of targets) { try { - await updateBranchProtection(target.owner, target.repo, target.branch, protection) + await updateBranchProtection( + target.owner, + target.repo, + target.branch, + protection, + ) results.push({ repo: { name: target.repo, @@ -146,24 +231,27 @@ export async function applyProtectionToMultiple( }) } } - + return results } -export async function detectLocalRepo(): Promise<{ owner: string; repo: string } | null> { +export async function detectLocalRepo(): Promise<{ + owner: string + repo: string +} | null> { try { const proc = Bun.spawn(['gh', 'repo', 'view', '--json', 'owner,name'], { stdout: 'pipe', stderr: 'pipe', }) - + const stdout = await new Response(proc.stdout as ReadableStream).text() const exitCode = await proc.exited - + if (exitCode !== 0 || !stdout.trim()) { return null } - + const data = JSON.parse(stdout) return { owner: data.owner.login, repo: data.name } } catch { @@ -178,12 +266,64 @@ export interface Workflow { state: string } -export async function getRepoWorkflows(owner: string, repo: string): Promise { +export async function getRepoWorkflows( + owner: string, + repo: string, +): Promise { try { - const result = await ghApi(`/repos/${owner}/${repo}/actions/workflows?per_page=100`) + const result = await ghApi( + `/repos/${owner}/${repo}/actions/workflows?per_page=100`, + ) const data = result as { workflows: Workflow[] } | null return data?.workflows ?? [] } catch { return [] } } + +export interface CheckJob { + name: string + workflowName: string +} + +export async function getAvailableChecks( + owner: string, + repo: string, +): Promise { + try { + const result = await ghApi( + `/repos/${owner}/${repo}/actions/runs?per_page=20&status=success`, + ) + const runsData = result as { + workflow_runs: Array<{ id: number; name: string }> + } | null + const runs = runsData?.workflow_runs ?? [] + + const checkSet = new Map() + + for (const run of runs) { + try { + const jobsResult = await ghApi( + `/repos/${owner}/${repo}/actions/runs/${run.id}/jobs?per_page=50`, + ) + const jobsData = jobsResult as { jobs: Array<{ name: string }> } | null + const jobs = jobsData?.jobs ?? [] + + for (const job of jobs) { + if (!checkSet.has(job.name)) { + checkSet.set(job.name, { + name: job.name, + workflowName: run.name, + }) + } + } + } catch {} + } + + return Array.from(checkSet.values()).sort((a, b) => + a.name.localeCompare(b.name), + ) + } catch { + return [] + } +} diff --git a/src/app.ts b/src/app.ts index 8bc7681..fb3213e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,4 @@ -import { createCliRenderer, BoxRenderable, TextRenderable, SelectRenderable, type CliRenderer } from '@opentui/core' +import { BoxRenderable, createCliRenderer, TextRenderable } from '@opentui/core' function logToFile(message: string) { try { @@ -6,32 +6,70 @@ function logToFile(message: string) { const timestamp = new Date().toISOString() const entry = `[${timestamp}] ${message}\n` Bun.write(logPath, entry).catch(() => {}) - } catch (e) { + } catch (_e) { // Ignore logging errors } } -import type { Organization, Repository, Branch, BranchProtection, BranchProtectionInput, ApplyResult } from './types' + +import { + applyProtectionToMultiple, + detectLocalRepo, + getBranchProtection, + getOrganizations, + getOrgRepos, + getRepoBranches, +} from './api/github' +import { + type BranchSelectorWithSet, + createBranchSelector, +} from './components/BranchSelector' +import { + blurSelect, + createOrgSelector, + updateOrgOptions, +} from './components/OrgSelector' +import { + createPreviewPane, + type PreviewPaneWithMethods, +} from './components/PreviewPane' +import { + createProtectionEditor, + type ProtectionEditorWithMethods, +} from './components/ProtectionEditor' +import { + createRepoSelector, + type RepoSelectorWithSet, +} from './components/RepoSelector' +import { + createTemplateManager, + type TemplateManagerWithRefresh, +} from './components/TemplateManager' import { theme } from './theme' -import { getOrganizations, getOrgRepos, getRepoBranches, getBranchProtection, applyProtectionToMultiple, detectLocalRepo } from './api/github' +import type { + ApplyResult, + BranchProtection, + BranchProtectionInput, + Organization, + Repository, +} from './types' import { initializeDefaultTemplates, saveTemplate } from './utils/templates' -import { createOrgSelector, updateOrgOptions, blurSelect, type OrgSelectorResult } from './components/OrgSelector' -import { createRepoSelector, type RepoSelectorWithSet } from './components/RepoSelector' -import { createBranchSelector, type BranchSelectorWithSet } from './components/BranchSelector' -import { createProtectionEditor, type ProtectionEditorWithMethods } from './components/ProtectionEditor' -import { createTemplateManager, type TemplateManagerWithRefresh } from './components/TemplateManager' -import { createPreviewPane, type PreviewPaneWithMethods } from './components/PreviewPane' type Screen = 'orgs' | 'repos' | 'branches' | 'editor' | 'templates' | 'preview' -interface KeyHandler { - (key: { name: string; shift: boolean; ctrl: boolean }): void | Promise -} +type KeyHandler = (key: { + name: string + shift: boolean + ctrl: boolean +}) => void | Promise export async function runApp(localMode: boolean = false): Promise { await initializeDefaultTemplates() - - const renderer = await createCliRenderer({ exitOnCtrlC: true }) - + + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + useMouse: false, + }) + const state: { screen: Screen org: Organization | null @@ -55,7 +93,7 @@ export async function runApp(localMode: boolean = false): Promise { isLoading: false, error: null, } - + const root = new BoxRenderable(renderer, { id: 'root', width: '100%', @@ -63,7 +101,7 @@ export async function runApp(localMode: boolean = false): Promise { flexDirection: 'column', backgroundColor: theme.bg, }) - + const header = new BoxRenderable(renderer, { id: 'header', width: '100%', @@ -74,10 +112,18 @@ export async function runApp(localMode: boolean = false): Promise { backgroundColor: theme.panelBg, padding: 1, }) - - const headerTitle = new TextRenderable(renderer, { id: 'header-title', content: 'RepoProtector', fg: theme.accent }) - const headerBreadcrumb = new TextRenderable(renderer, { id: 'header-breadcrumb', content: '', fg: theme.textMuted }) - + + const headerTitle = new TextRenderable(renderer, { + id: 'header-title', + content: 'RepoProtector', + fg: theme.accent, + }) + const headerBreadcrumb = new TextRenderable(renderer, { + id: 'header-breadcrumb', + content: '', + fg: theme.textMuted, + }) + const mainContent = new BoxRenderable(renderer, { id: 'main-content', width: '100%', @@ -85,7 +131,7 @@ export async function runApp(localMode: boolean = false): Promise { flexDirection: 'column', backgroundColor: theme.bg, }) - + const footer = new BoxRenderable(renderer, { id: 'footer', width: '100%', @@ -95,9 +141,13 @@ export async function runApp(localMode: boolean = false): Promise { backgroundColor: theme.panelBg, padding: 0, }) - - const footerText = new TextRenderable(renderer, { id: 'footer-text', content: '', fg: theme.textMuted }) - + + const footerText = new TextRenderable(renderer, { + id: 'footer-text', + content: '', + fg: theme.textMuted, + }) + header.add(headerTitle) header.add(headerBreadcrumb) footer.add(footerText) @@ -105,50 +155,68 @@ export async function runApp(localMode: boolean = false): Promise { root.add(mainContent) root.add(footer) renderer.root.add(root) - + let currentScreenComponent: BoxRenderable | null = null let currentKeyHandler: KeyHandler | null = null - + const updateBreadcrumb = () => { const parts: string[] = [] if (state.org) parts.push(state.org.login) if (state.selectedRepos.length > 0) { - parts.push(state.selectedRepos.length === 1 ? state.selectedRepos[0]!.name : `${state.selectedRepos.length} repos`) + parts.push( + state.selectedRepos.length === 1 + ? state.selectedRepos[0]!.name + : `${state.selectedRepos.length} repos`, + ) } if (state.branch) parts.push(state.branch) headerBreadcrumb.content = parts.join(' > ') } - - const updateFooter = (text: string) => { footerText.content = text } - const showLoading = (message: string) => { state.isLoading = true; updateFooter(`Loading... ${message}`) } - const hideLoading = () => { state.isLoading = false; updateFooter('') } - const showError = (message: string) => { state.error = message; updateFooter(`Error: ${message}`) } - + + const updateFooter = (text: string) => { + footerText.content = text + } + const showLoading = (message: string) => { + state.isLoading = true + updateFooter(`Loading... ${message}`) + } + const hideLoading = () => { + state.isLoading = false + updateFooter('') + } + const showError = (message: string) => { + state.error = message + updateFooter(`Error: ${message}`) + } + const clearScreen = () => { if (currentScreenComponent) { - if ('blur' in currentScreenComponent && typeof currentScreenComponent.blur === 'function') { - (currentScreenComponent as { blur: () => void }).blur() + if ( + 'blur' in currentScreenComponent && + typeof currentScreenComponent.blur === 'function' + ) { + ;(currentScreenComponent as { blur: () => void }).blur() } mainContent.remove(currentScreenComponent.id) currentScreenComponent = null } currentKeyHandler = null } - + const showOrgSelector = async () => { clearScreen() state.screen = 'orgs' updateBreadcrumb() updateFooter('Select an organization to manage') - - const result = createOrgSelector(renderer, (org) => { + + const result = createOrgSelector(renderer, (org) => { blurSelect(result.select) - state.org = org - showRepoSelector() + state.org = org + showRepoSelector() }) mainContent.add(result.container) currentScreenComponent = result.container - + showLoading('Fetching organizations...') try { const orgs = await getOrganizations() @@ -156,37 +224,38 @@ export async function runApp(localMode: boolean = false): Promise { hideLoading() } catch (err) { hideLoading() - showError(err instanceof Error ? err.message : 'Failed to load organizations') + showError( + err instanceof Error ? err.message : 'Failed to load organizations', + ) } } - + const showRepoSelector = async () => { if (!state.org) return clearScreen() state.screen = 'repos' updateBreadcrumb() updateFooter('Space to toggle, Enter to confirm') - + const container = createRepoSelector( renderer, - (repos) => { + (repos) => { container.blur() - state.selectedRepos = repos; - showBranchSelector() + state.selectedRepos = repos + showBranchSelector() }, - () => { + () => { container.blur() - showOrgSelector() - } + showOrgSelector() + }, ) as RepoSelectorWithSet - + mainContent.add(container) currentScreenComponent = container currentKeyHandler = (key) => { container.handleKey(key) - } - + showLoading('Fetching repositories...') try { const repos = await getOrgRepos(state.org.login) @@ -195,17 +264,19 @@ export async function runApp(localMode: boolean = false): Promise { hideLoading() } catch (err) { hideLoading() - showError(err instanceof Error ? err.message : 'Failed to load repositories') + showError( + err instanceof Error ? err.message : 'Failed to load repositories', + ) } } - + const showBranchSelector = async () => { if (state.selectedRepos.length === 0) return clearScreen() state.screen = 'branches' updateBreadcrumb() updateFooter('Select a branch to protect') - + const repo = state.selectedRepos[0]! const container = createBranchSelector( renderer, @@ -214,27 +285,32 @@ export async function runApp(localMode: boolean = false): Promise { state.branch = branch showLoading('Fetching current protection...') try { - state.currentProtection = await getBranchProtection(repo.owner.login, repo.name, branch) + state.currentProtection = await getBranchProtection( + repo.owner.login, + repo.name, + branch, + ) hideLoading() await showEditor() } catch (err) { hideLoading() - showError(err instanceof Error ? err.message : 'Failed to load protection') + showError( + err instanceof Error ? err.message : 'Failed to load protection', + ) } }, - () => { + () => { container.blur() - showRepoSelector() - } + showRepoSelector() + }, ) as BranchSelectorWithSet - + mainContent.add(container) currentScreenComponent = container currentKeyHandler = (key) => { container.handleKey(key) - } - + showLoading('Fetching branches...') try { const branches = await getRepoBranches(repo.owner.login, repo.name) @@ -245,51 +321,52 @@ export async function runApp(localMode: boolean = false): Promise { showError(err instanceof Error ? err.message : 'Failed to load branches') } } - + const showEditor = async () => { clearScreen() state.screen = 'editor' updateBreadcrumb() showLoading('Loading editor...') - + const repo = state.selectedRepos[0]! const container = createProtectionEditor( renderer, - (protection) => { state.proposedProtection = protection; showPreview() }, - () => showBranchSelector() + (protection) => { + state.proposedProtection = protection + showPreview() + }, + () => showBranchSelector(), ) as ProtectionEditorWithMethods - + container.setProtection(state.currentProtection) - + mainContent.add(container) currentScreenComponent = container - + await container.setRepoInfo(repo.owner.login, repo.name) - + hideLoading() updateFooter('Configure protection settings') - + currentKeyHandler = (key) => { if (key.ctrl && key.name === 's' && state.proposedProtection) { const name = `template-${Date.now()}` saveTemplate(name, state.proposedProtection, 'Saved from editor') updateFooter(`Saved as template: ${name}`) - } else if (key.ctrl && key.name === 't') { showTemplates() return } container.handleKey(key) - } } - + const showTemplates = async () => { clearScreen() state.screen = 'templates' updateBreadcrumb() showLoading('Loading templates...') - + const container = createTemplateManager( renderer, (protection) => { @@ -297,41 +374,47 @@ export async function runApp(localMode: boolean = false): Promise { state.proposedProtection = protection showEditor() setTimeout(() => { - if (currentScreenComponent && 'setProtection' in currentScreenComponent && 'setRepoInfo' in currentScreenComponent) { + if ( + currentScreenComponent && + 'setProtection' in currentScreenComponent && + 'setRepoInfo' in currentScreenComponent + ) { const editor = currentScreenComponent as ProtectionEditorWithMethods editor.setProtection(protection) if (state.selectedRepos[0]) { - editor.setRepoInfo(state.selectedRepos[0].owner.login, state.selectedRepos[0].name) + editor.setRepoInfo( + state.selectedRepos[0].owner.login, + state.selectedRepos[0].name, + ) } } }, 50) }, - () => { + () => { container.blur() - state.org ? showRepoSelector() : showOrgSelector() - } + state.org ? showRepoSelector() : showOrgSelector() + }, ) as TemplateManagerWithRefresh - + mainContent.add(container) currentScreenComponent = container - + await container.refresh() hideLoading() updateFooter('Load a template') - + currentKeyHandler = async (key) => { await container.handleKey(key) - } } - + const showPreview = () => { if (!state.proposedProtection) return clearScreen() state.screen = 'preview' updateBreadcrumb() updateFooter('Review and apply changes') - + const container = createPreviewPane( renderer, async () => { @@ -343,7 +426,10 @@ export async function runApp(localMode: boolean = false): Promise { branch: state.branch!, })) try { - const results = await applyProtectionToMultiple(targets, state.proposedProtection) + const results = await applyProtectionToMultiple( + targets, + state.proposedProtection, + ) logToFile(`Applied protection to ${targets.length} repo(s)`) logToFile(JSON.stringify(state.proposedProtection, null, 2)) state.results = results @@ -351,34 +437,44 @@ export async function runApp(localMode: boolean = false): Promise { container.setResults(results) } catch (err) { hideLoading() - showError(err instanceof Error ? err.message : 'Failed to apply protection') + showError( + err instanceof Error ? err.message : 'Failed to apply protection', + ) } }, - () => showEditor() + () => showEditor(), ) as PreviewPaneWithMethods - + container.setDiff(state.currentProtection, state.proposedProtection) mainContent.add(container) currentScreenComponent = container currentKeyHandler = (key) => { container.handleKey(key) - } } - - renderer.keyInput.on('keypress', (key: { name: string; shift: boolean; ctrl: boolean }) => { - if (state.isLoading) return - if (currentKeyHandler) { - currentKeyHandler(key) - } - }) - + + renderer.keyInput.on( + 'keypress', + (key: { name: string; shift: boolean; ctrl: boolean }) => { + if (state.isLoading) return + if (currentKeyHandler) { + currentKeyHandler(key) + } + }, + ) + if (localMode) { showLoading('Detecting local repository...') const localRepo = await detectLocalRepo() if (localRepo) { state.org = { login: localRepo.owner } as Organization - state.selectedRepos = [{ name: localRepo.repo, full_name: `${localRepo.owner}/${localRepo.repo}`, owner: { login: localRepo.owner } } as Repository] + state.selectedRepos = [ + { + name: localRepo.repo, + full_name: `${localRepo.owner}/${localRepo.repo}`, + owner: { login: localRepo.owner }, + } as Repository, + ] hideLoading() showBranchSelector() } else { diff --git a/src/components/BranchSelector.ts b/src/components/BranchSelector.ts index 2465c64..41714ed 100644 --- a/src/components/BranchSelector.ts +++ b/src/components/BranchSelector.ts @@ -1,13 +1,19 @@ -import { BoxRenderable, TextRenderable, SelectRenderable, SelectRenderableEvents, type CliRenderer } from '@opentui/core' -import type { Branch } from '../types' +import { + BoxRenderable, + type CliRenderer, + SelectRenderable, + SelectRenderableEvents, + TextRenderable, +} from '@opentui/core' import { theme } from '../theme' +import type { Branch } from '../types' export type BranchSelectedCallback = (branch: string) => void export function createBranchSelector( renderer: CliRenderer, onSelect: BranchSelectedCallback, - onBack: () => void + onBack: () => void, ): BoxRenderable { const container = new BoxRenderable(renderer, { id: 'branch-selector', @@ -17,19 +23,19 @@ export function createBranchSelector( backgroundColor: theme.panelBg, padding: 1, }) - + const title = new TextRenderable(renderer, { id: 'branch-title', content: 'Select Branch', fg: theme.accent, }) - + const helpText = new TextRenderable(renderer, { id: 'branch-help', content: '↑/↓ Navigate | Enter Select | Esc Back', fg: theme.textMuted, }) - + const select = new SelectRenderable(renderer, { id: 'branch-select', width: '100%', @@ -43,23 +49,23 @@ export function createBranchSelector( showDescription: true, wrapSelection: true, }) - + select.on(SelectRenderableEvents.ITEM_SELECTED, (_index, option) => { if (option?.value) { onSelect(option.value as string) } }) - + const handleKey = (key: { name: string }) => { if (key.name === 'escape') { onBack() } } - + container.add(title) container.add(select) container.add(helpText) - + const setBranches = (branches: Branch[]) => { select.options = branches.map((branch) => ({ name: branch.name, @@ -68,15 +74,15 @@ export function createBranchSelector( })) select.focus() } - + const blur = () => { select.blur() } - + return Object.assign(container, { setBranches, handleKey, blur }) } -export type BranchSelectorWithSet = BoxRenderable & { +export type BranchSelectorWithSet = BoxRenderable & { setBranches: (branches: Branch[]) => void handleKey: (key: { name: string }) => void blur: () => void diff --git a/src/components/OrgSelector.ts b/src/components/OrgSelector.ts index 7fe661d..5bf07c0 100644 --- a/src/components/OrgSelector.ts +++ b/src/components/OrgSelector.ts @@ -1,12 +1,12 @@ import { BoxRenderable, - TextRenderable, + type CliRenderer, SelectRenderable, SelectRenderableEvents, - type CliRenderer, + TextRenderable, } from '@opentui/core' -import type { Organization } from '../types' import { theme } from '../theme' +import type { Organization } from '../types' export type OrgSelectedCallback = (org: Organization) => void @@ -17,7 +17,7 @@ export interface OrgSelectorResult { export function createOrgSelector( renderer: CliRenderer, - onSelect: OrgSelectedCallback + onSelect: OrgSelectedCallback, ): OrgSelectorResult { const container = new BoxRenderable(renderer, { id: 'org-selector', @@ -27,19 +27,19 @@ export function createOrgSelector( backgroundColor: theme.panelBg, padding: 1, }) - + const title = new TextRenderable(renderer, { id: 'org-title', content: 'Select Organization', fg: theme.accent, }) - + const helpText = new TextRenderable(renderer, { id: 'org-help', content: '↑/↓ Navigate | Enter Select | Ctrl+C Quit', fg: theme.textMuted, }) - + const select = new SelectRenderable(renderer, { id: 'org-select', width: '100%', @@ -53,21 +53,24 @@ export function createOrgSelector( showDescription: true, wrapSelection: true, }) - + select.on(SelectRenderableEvents.ITEM_SELECTED, (_index, option) => { if (option?.value) { onSelect(option.value as Organization) } }) - + container.add(title) container.add(select) container.add(helpText) - + return { container, select } } -export function updateOrgOptions(select: SelectRenderable, orgs: Organization[]): void { +export function updateOrgOptions( + select: SelectRenderable, + orgs: Organization[], +): void { select.options = orgs.map((org) => ({ name: org.login, description: org.description || 'No description', diff --git a/src/components/PreviewPane.ts b/src/components/PreviewPane.ts index 689096d..ba485aa 100644 --- a/src/components/PreviewPane.ts +++ b/src/components/PreviewPane.ts @@ -1,6 +1,10 @@ -import { BoxRenderable, TextRenderable, type CliRenderer } from '@opentui/core' -import type { BranchProtection, BranchProtectionInput, ApplyResult } from '../types' +import { BoxRenderable, type CliRenderer, TextRenderable } from '@opentui/core' import { theme } from '../theme' +import type { + ApplyResult, + BranchProtection, + BranchProtectionInput, +} from '../types' function logToFile(message: string) { try { @@ -8,7 +12,7 @@ function logToFile(message: string) { const timestamp = new Date().toISOString() const entry = `[${timestamp}] ${message}\n` Bun.write(logPath, entry).catch(() => {}) - } catch (e) { + } catch (_e) { // Ignore logging errors } } @@ -26,7 +30,7 @@ function formatValue(val: unknown, indent: string = ''): string[] { if (val.length === 0) return [`${indent}[]`] const lines: string[] = [`${indent}[`] for (const item of val) { - lines.push(...formatValue(item, indent + ' ')) + lines.push(...formatValue(item, `${indent} `)) } lines.push(`${indent}]`) return lines @@ -36,8 +40,8 @@ function formatValue(val: unknown, indent: string = ''): string[] { if (entries.length === 0) return [`${indent}{}`] const lines: string[] = [`${indent}{`] for (const [k, v] of entries) { - const subLines = formatValue(v, indent + ' ') - lines.push(`${subLines[0]?.replace(indent + ' ', indent + ' ' + k + ': ')}`) + const subLines = formatValue(v, `${indent} `) + lines.push(`${subLines[0]?.replace(`${indent} `, `${indent} ${k}: `)}`) lines.push(...subLines.slice(1)) } lines.push(`${indent}}`) @@ -48,23 +52,25 @@ function formatValue(val: unknown, indent: string = ''): string[] { function diffProtection( current: BranchProtection | null, - proposed: BranchProtectionInput + proposed: BranchProtectionInput, ): { added: string[]; removed: string[]; changed: string[] } { const added: string[] = [] const removed: string[] = [] const changed: string[] = [] - + const keys = new Set([ ...Object.keys(current || {}), ...Object.keys(proposed), ]) - + for (const key of keys) { if (key === 'url') continue - - const currentVal = current ? (current as unknown as Record)[key] : undefined + + const currentVal = current + ? (current as unknown as Record)[key] + : undefined const proposedVal = (proposed as Record)[key] - + if (currentVal === undefined && proposedVal !== undefined) { added.push(key) } else if (currentVal !== undefined && proposedVal === undefined) { @@ -73,14 +79,14 @@ function diffProtection( changed.push(key) } } - + return { added, removed, changed } } export function createPreviewPane( renderer: CliRenderer, onConfirm: PreviewConfirmCallback, - onCancel: PreviewCancelCallback + onCancel: PreviewCancelCallback, ): BoxRenderable { const container = new BoxRenderable(renderer, { id: 'preview-pane', @@ -90,13 +96,13 @@ export function createPreviewPane( backgroundColor: theme.panelBg, padding: 1, }) - + const title = new TextRenderable(renderer, { id: 'preview-title', content: 'Preview Changes', fg: theme.accent, }) - + const contentBox = new BoxRenderable(renderer, { id: 'preview-content', width: '100%', @@ -105,20 +111,20 @@ export function createPreviewPane( backgroundColor: theme.bg, padding: 1, }) - + const footer = new BoxRenderable(renderer, { id: 'preview-footer', width: '100%', flexDirection: 'row', justifyContent: 'space-between', }) - + const helpText = new TextRenderable(renderer, { id: 'preview-help', content: 'Enter Apply | Esc Cancel', fg: theme.textMuted, }) - + const state: { current: BranchProtection | null proposed: BranchProtectionInput | null @@ -130,10 +136,10 @@ export function createPreviewPane( results: [], mode: 'diff', } - + const renderDiff = () => { contentBox.remove('diff-text') - + if (!state.proposed) { const text = new TextRenderable(renderer, { id: 'diff-text', @@ -143,10 +149,10 @@ export function createPreviewPane( contentBox.add(text) return } - + const diff = diffProtection(state.current, state.proposed) const lines: string[] = [] - + if (diff.added.length > 0) { lines.push(`+ Added: ${diff.added.join(', ')}`) } @@ -156,14 +162,14 @@ export function createPreviewPane( if (diff.changed.length > 0) { lines.push(`~ Changed: ${diff.changed.join(', ')}`) } - + if (lines.length === 0) { lines.push('No changes detected') } - + lines.push('', '--- Proposed Settings ---') lines.push(...formatValue(state.proposed, '').slice(1, 30)) - + const text = new TextRenderable(renderer, { id: 'diff-text', content: lines.join('\n'), @@ -171,15 +177,15 @@ export function createPreviewPane( }) contentBox.add(text) } - + const renderResults = () => { contentBox.remove('results-text') - + const lines: string[] = ['Apply Results:', ''] - + let success = 0 let failed = 0 - + for (const result of state.results) { if (result.success) { const msg = `✓ ${result.repo.full_name}:${result.branch}` @@ -193,11 +199,11 @@ export function createPreviewPane( failed++ } } - + const summary = `Total: ${state.results.length} | Success: ${success} | Failed: ${failed}` lines.push('', summary) logToFile(summary) - + const text = new TextRenderable(renderer, { id: 'results-text', content: lines.join('\n'), @@ -205,7 +211,7 @@ export function createPreviewPane( }) contentBox.add(text) } - + const handleKey = (key: { name: string }) => { if (key.name === 'return' || key.name === 'enter') { if (state.mode === 'diff') { @@ -217,30 +223,36 @@ export function createPreviewPane( onCancel() } } - + footer.add(helpText) container.add(title) container.add(contentBox) container.add(footer) - - const setDiff = (current: BranchProtection | null, proposed: BranchProtectionInput) => { + + const setDiff = ( + current: BranchProtection | null, + proposed: BranchProtectionInput, + ) => { state.current = current state.proposed = proposed state.mode = 'diff' renderDiff() } - + const setResults = (results: ApplyResult[]) => { state.results = results state.mode = 'results' renderResults() } - + return Object.assign(container, { setDiff, setResults, handleKey }) } export type PreviewPaneWithMethods = BoxRenderable & { - setDiff: (current: BranchProtection | null, proposed: BranchProtectionInput) => void + setDiff: ( + current: BranchProtection | null, + proposed: BranchProtectionInput, + ) => void setResults: (results: ApplyResult[]) => void handleKey: (key: { name: string }) => void } diff --git a/src/components/ProtectionEditor.ts b/src/components/ProtectionEditor.ts index 6c7cdf6..44c70f9 100644 --- a/src/components/ProtectionEditor.ts +++ b/src/components/ProtectionEditor.ts @@ -1,25 +1,25 @@ import { BoxRenderable, - TextRenderable, + type CliRenderer, InputRenderable, InputRenderableEvents, - type CliRenderer, type Renderable, + TextRenderable, } from '@opentui/core' -import type { BranchProtectionInput } from '../types' +import { type CheckJob, getAvailableChecks } from '../api/github' import { theme } from '../theme' -import { getRepoWorkflows, type Workflow } from '../api/github' +import type { BranchProtectionInput } from '../types' interface EditorField { key: string label: string - type: 'boolean' | 'number' | 'string' | 'workflow-select' + type: 'boolean' | 'number' | 'string' | 'check-select' value: boolean | number | string | string[] parent?: string } -interface WorkflowItem { - workflow: Workflow +interface CheckItem { + check: CheckJob selected: boolean } @@ -29,7 +29,7 @@ export type ProtectionCancelCallback = () => void export function createProtectionEditor( renderer: CliRenderer, onSave: ProtectionSaveCallback, - onCancel: ProtectionCancelCallback + onCancel: ProtectionCancelCallback, ): BoxRenderable { const container = new BoxRenderable(renderer, { id: 'protection-editor', @@ -39,13 +39,13 @@ export function createProtectionEditor( backgroundColor: theme.panelBg, padding: 1, }) - + const title = new TextRenderable(renderer, { id: 'editor-title', content: 'Branch Protection Settings', fg: theme.accent, }) - + const scrollContent = new BoxRenderable(renderer, { id: 'editor-scroll', width: '100%', @@ -53,31 +53,32 @@ export function createProtectionEditor( flexDirection: 'column', gap: 0, }) - + const footer = new BoxRenderable(renderer, { id: 'editor-footer', width: '100%', flexDirection: 'row', justifyContent: 'space-between', }) - + const helpText = new TextRenderable(renderer, { id: 'editor-help', - content: '↑/↓ Nav | Enter Toggle/Edit | Tab Next | Ctrl+A Apply | Esc Back', + content: + '↑/↓ Nav | Enter Toggle/Edit | Tab Next | Ctrl+A Apply | Esc Back', fg: theme.textMuted, }) - - const state: { + + const state: { protection: BranchProtectionInput fields: EditorField[] focusIndex: number inputs: Map rowIds: string[] - workflows: Workflow[] + availableChecks: CheckJob[] repoInfo: { owner: string; repo: string } | null - showWorkflowModal: boolean - workflowItems: WorkflowItem[] - workflowFocusIndex: number + showCheckModal: boolean + checkItems: CheckItem[] + checkFocusIndex: number modalRowIds: string[] } = { protection: createDefaultProtection(), @@ -85,61 +86,159 @@ export function createProtectionEditor( focusIndex: 0, inputs: new Map(), rowIds: [], - workflows: [], + availableChecks: [], repoInfo: null, - showWorkflowModal: false, - workflowItems: [], - workflowFocusIndex: 0, + showCheckModal: false, + checkItems: [], + checkFocusIndex: 0, modalRowIds: [], } - + const buildFields = (): EditorField[] => { const p = state.protection const fields: EditorField[] = [ - { key: 'enforce_admins', label: 'Enforce for admins', type: 'boolean', value: p.enforce_admins ?? false }, - { key: 'required_linear_history', label: 'Require linear history', type: 'boolean', value: p.required_linear_history ?? false }, - { key: 'allow_force_pushes', label: 'Allow force pushes', type: 'boolean', value: p.allow_force_pushes ?? false }, - { key: 'allow_deletions', label: 'Allow deletions', type: 'boolean', value: p.allow_deletions ?? false }, - { key: 'block_creations', label: 'Block creations', type: 'boolean', value: p.block_creations ?? false }, - { key: 'required_conversation_resolution', label: 'Require conversation resolution', type: 'boolean', value: p.required_conversation_resolution ?? true }, + { + key: 'enforce_admins', + label: 'Enforce for admins', + type: 'boolean', + value: p.enforce_admins ?? false, + }, + { + key: 'required_linear_history', + label: 'Require linear history', + type: 'boolean', + value: p.required_linear_history ?? false, + }, + { + key: 'allow_force_pushes', + label: 'Allow force pushes', + type: 'boolean', + value: p.allow_force_pushes ?? false, + }, + { + key: 'allow_deletions', + label: 'Allow deletions', + type: 'boolean', + value: p.allow_deletions ?? false, + }, + { + key: 'block_creations', + label: 'Block creations', + type: 'boolean', + value: p.block_creations ?? false, + }, + { + key: 'required_conversation_resolution', + label: 'Require conversation resolution', + type: 'boolean', + value: p.required_conversation_resolution ?? true, + }, ] - + if (p.required_pull_request_reviews) { const rpr = p.required_pull_request_reviews fields.push( - { key: 'rpr_enabled', label: 'PR Reviews Enabled', type: 'boolean', value: true, parent: 'required_pull_request_reviews' }, - { key: 'dismiss_stale_reviews', label: ' Dismiss stale reviews', type: 'boolean', value: rpr.dismiss_stale_reviews ?? false, parent: 'required_pull_request_reviews' }, - { key: 'require_code_owner_reviews', label: ' Require code owner reviews', type: 'boolean', value: rpr.require_code_owner_reviews ?? false, parent: 'required_pull_request_reviews' }, - { key: 'required_approving_review_count', label: ' Required approvals', type: 'number', value: rpr.required_approving_review_count ?? 1, parent: 'required_pull_request_reviews' }, + { + key: 'rpr_enabled', + label: 'PR Reviews Enabled', + type: 'boolean', + value: true, + parent: 'required_pull_request_reviews', + }, + { + key: 'dismiss_stale_reviews', + label: ' Dismiss stale reviews', + type: 'boolean', + value: rpr.dismiss_stale_reviews ?? false, + parent: 'required_pull_request_reviews', + }, + { + key: 'require_code_owner_reviews', + label: ' Require code owner reviews', + type: 'boolean', + value: rpr.require_code_owner_reviews ?? false, + parent: 'required_pull_request_reviews', + }, + { + key: 'required_approving_review_count', + label: ' Required approvals', + type: 'number', + value: rpr.required_approving_review_count ?? 1, + parent: 'required_pull_request_reviews', + }, ) } else { - fields.push({ key: 'rpr_enabled', label: 'PR Reviews Enabled', type: 'boolean', value: false, parent: 'required_pull_request_reviews' }) + fields.push({ + key: 'rpr_enabled', + label: 'PR Reviews Enabled', + type: 'boolean', + value: false, + parent: 'required_pull_request_reviews', + }) } - + if (p.required_status_checks) { const rsc = p.required_status_checks fields.push( - { key: 'rsc_enabled', label: 'Status Checks Enabled', type: 'boolean', value: true, parent: 'required_status_checks' }, - { key: 'strict', label: ' Require branches up-to-date', type: 'boolean', value: rsc.strict ?? false, parent: 'required_status_checks' }, - { key: 'contexts', label: ' Status checks (comma-sep)', type: 'string', value: rsc.contexts?.join(', ') ?? '', parent: 'required_status_checks' }, + { + key: 'rsc_enabled', + label: 'Status Checks Enabled', + type: 'boolean', + value: true, + parent: 'required_status_checks', + }, + { + key: 'strict', + label: ' Require branches up-to-date', + type: 'boolean', + value: rsc.strict ?? false, + parent: 'required_status_checks', + }, + { + key: 'contexts', + label: ' Status checks (comma-sep)', + type: 'string', + value: rsc.contexts?.join(', ') ?? '', + parent: 'required_status_checks', + }, ) - if (state.workflows.length > 0) { - fields.push( - { key: 'add_workflow', label: ' [+] Add workflow checks', type: 'workflow-select', value: '', parent: 'required_status_checks' }, - ) + if (state.availableChecks.length > 0) { + fields.push({ + key: 'add_check', + label: ' [+] Add status checks from CI', + type: 'check-select', + value: '', + parent: 'required_status_checks', + }) } } else { - fields.push({ key: 'rsc_enabled', label: 'Status Checks Enabled', type: 'boolean', value: false, parent: 'required_status_checks' }) + fields.push({ + key: 'rsc_enabled', + label: 'Status Checks Enabled', + type: 'boolean', + value: false, + parent: 'required_status_checks', + }) } - + fields.push( - { key: 'divider_apply', label: '─────────────────────────', type: 'boolean', value: false }, - { key: 'apply', label: '>>> APPLY PROTECTION <<<', type: 'boolean', value: false }, + { + key: 'divider_apply', + label: '─────────────────────────', + type: 'boolean', + value: false, + }, + { + key: 'apply', + label: '>>> APPLY PROTECTION <<<', + type: 'boolean', + value: false, + }, ) - + return fields } - + const clearRows = () => { for (const rowId of state.rowIds) { scrollContent.remove(rowId) @@ -147,16 +246,16 @@ export function createProtectionEditor( state.rowIds = [] state.inputs.clear() } - + const renderFields = () => { clearRows() state.fields = buildFields() - + for (let i = 0; i < state.fields.length; i++) { const field = state.fields[i]! const isFocused = i === state.focusIndex const rowId = `field-row-${i}` - + if (field.key === 'divider_apply') { const divider = new TextRenderable(renderer, { id: rowId, @@ -167,7 +266,7 @@ export function createProtectionEditor( state.rowIds.push(rowId) continue } - + if (field.key === 'apply') { const applyBtn = new BoxRenderable(renderer, { id: rowId, @@ -177,19 +276,19 @@ export function createProtectionEditor( padding: 1, backgroundColor: isFocused ? theme.accent : theme.selectedBg, }) - + const btnText = new TextRenderable(renderer, { id: `btn-text-${rowId}`, content: '>>> APPLY PROTECTION <<<', fg: isFocused ? theme.bg : theme.text, }) - + applyBtn.add(btnText) scrollContent.add(applyBtn) state.rowIds.push(rowId) continue } - + const row = new BoxRenderable(renderer, { id: rowId, width: '100%', @@ -198,15 +297,15 @@ export function createProtectionEditor( padding: 0, backgroundColor: isFocused ? theme.selectedBg : 'transparent', }) - + const label = new TextRenderable(renderer, { id: `label-${rowId}`, content: field.label, fg: field.parent ? theme.textMuted : theme.text, }) - + let valueDisplay: Renderable - + if (field.type === 'boolean') { valueDisplay = new TextRenderable(renderer, { id: `value-${rowId}`, @@ -222,18 +321,18 @@ export function createProtectionEditor( textColor: theme.text, cursorColor: theme.input.cursor, }) - + input.on(InputRenderableEvents.INPUT, (val: string) => { const num = parseInt(val, 10) - if (!isNaN(num) && num >= 0) { + if (!Number.isNaN(num) && num >= 0) { field.value = num updateProtectionFromField(field) } }) - + valueDisplay = input state.inputs.set(field.key, input) - } else if (field.type === 'workflow-select') { + } else if (field.type === 'check-select') { const btn = new TextRenderable(renderer, { id: `value-${rowId}`, content: '', @@ -249,37 +348,41 @@ export function createProtectionEditor( textColor: theme.text, cursorColor: theme.input.cursor, }) - + input.on(InputRenderableEvents.INPUT, (val: string) => { field.value = val updateProtectionFromField(field) }) - + valueDisplay = input state.inputs.set(field.key, input) } - + row.add(label) row.add(valueDisplay) scrollContent.add(row) state.rowIds.push(rowId) } - + focusCurrentField() } - + const updateProtectionFromField = (field: EditorField) => { const p = state.protection - + if (field.parent === 'required_pull_request_reviews') { if (field.key === 'rpr_enabled') { - p.required_pull_request_reviews = field.value - ? { dismiss_stale_reviews: false, require_code_owner_reviews: false, required_approving_review_count: 1 } + p.required_pull_request_reviews = field.value + ? { + dismiss_stale_reviews: false, + require_code_owner_reviews: false, + required_approving_review_count: 1, + } : null renderFields() return } - + if (p.required_pull_request_reviews) { const rpr = p.required_pull_request_reviews if (field.key === 'dismiss_stale_reviews') { @@ -292,13 +395,13 @@ export function createProtectionEditor( } } else if (field.parent === 'required_status_checks') { if (field.key === 'rsc_enabled') { - p.required_status_checks = field.value + p.required_status_checks = field.value ? { strict: false, contexts: [] } : null renderFields() return } - + if (p.required_status_checks) { const rsc = p.required_status_checks if (field.key === 'strict') { @@ -333,11 +436,11 @@ export function createProtectionEditor( } } } - + const focusCurrentField = () => { const field = state.fields[state.focusIndex] if (!field) return - + if (field.type === 'number' || field.type === 'string') { const input = state.inputs.get(field.key) if (input) { @@ -345,34 +448,34 @@ export function createProtectionEditor( } } } - - const addWorkflowChecks = (workflowNames: string[]) => { + + const addCheckNames = (checkNames: string[]) => { const p = state.protection if (!p.required_status_checks) { p.required_status_checks = { strict: false, contexts: [] } } - - for (const name of workflowNames) { + + for (const name of checkNames) { if (!p.required_status_checks.contexts.includes(name)) { p.required_status_checks.contexts.push(name) } } - + renderFields() } - + const clearModalRows = () => { for (const rowId of state.modalRowIds) { container.remove(rowId) } state.modalRowIds = [] } - - const renderWorkflowModal = () => { + + const renderCheckModal = () => { clearModalRows() - + const modalOverlay = new BoxRenderable(renderer, { - id: 'workflow-modal-overlay', + id: 'check-modal-overlay', position: 'absolute', top: 3, left: 2, @@ -384,34 +487,34 @@ export function createProtectionEditor( flexDirection: 'column', padding: 1, }) - + const modalTitle = new TextRenderable(renderer, { id: 'modal-title', - content: 'Select Workflows to Add as Status Checks', + content: 'Select CI Job Names to Add as Required Checks', fg: theme.accent, }) modalOverlay.add(modalTitle) - + const modalHelp = new TextRenderable(renderer, { id: 'modal-help', content: 'Space Toggle | Enter Apply | Esc Cancel', fg: theme.textMuted, }) - + const listContainer = new BoxRenderable(renderer, { - id: 'workflow-list', + id: 'check-list', width: '100%', flexGrow: 1, flexDirection: 'column', backgroundColor: theme.panelBg, padding: 1, }) - - for (let i = 0; i < state.workflowItems.length; i++) { - const item = state.workflowItems[i]! - const isFocused = i === state.workflowFocusIndex - const rowId = `workflow-row-${i}` - + + for (let i = 0; i < state.checkItems.length; i++) { + const item = state.checkItems[i]! + const isFocused = i === state.checkFocusIndex + const rowId = `check-row-${i}` + const row = new BoxRenderable(renderer, { id: rowId, width: '100%', @@ -419,164 +522,179 @@ export function createProtectionEditor( backgroundColor: isFocused ? theme.selectedBg : 'transparent', padding: 0, }) - + const checkbox = new TextRenderable(renderer, { id: `checkbox-${i}`, content: item.selected ? '✓ ' : '○ ', fg: item.selected ? theme.success : theme.textDim, }) - + const name = new TextRenderable(renderer, { id: `name-${i}`, - content: item.workflow.name, + content: item.check.name, fg: isFocused ? theme.accent : theme.text, }) - - const path = new TextRenderable(renderer, { - id: `path-${i}`, - content: ` ${item.workflow.path}`, + + const workflow = new TextRenderable(renderer, { + id: `workflow-${i}`, + content: ` (${item.check.workflowName})`, fg: theme.textMuted, }) - + row.add(checkbox) row.add(name) - row.add(path) + row.add(workflow) listContainer.add(row) } - + const countText = new TextRenderable(renderer, { id: 'selected-count', - content: `${state.workflowItems.filter(w => w.selected).length} selected`, + content: `${state.checkItems.filter((c) => c.selected).length} selected`, fg: theme.accentPurple, }) - + modalOverlay.add(listContainer) modalOverlay.add(countText) modalOverlay.add(modalHelp) - + container.add(modalOverlay) - state.modalRowIds.push('workflow-modal-overlay') + state.modalRowIds.push('check-modal-overlay') } - - const showWorkflowModal = () => { - if (state.workflows.length === 0) { + + const showCheckModal = () => { + if (state.availableChecks.length === 0) { return } - - state.workflowItems = state.workflows.map((w) => { - const alreadyAdded = state.protection.required_status_checks?.contexts?.includes(w.name) ?? false - return { workflow: w, selected: alreadyAdded } + + state.checkItems = state.availableChecks.map((c) => { + const alreadyAdded = + state.protection.required_status_checks?.contexts?.includes(c.name) ?? + false + return { check: c, selected: alreadyAdded } }) - state.workflowFocusIndex = 0 - state.showWorkflowModal = true - - renderWorkflowModal() + state.checkFocusIndex = 0 + state.showCheckModal = true + + renderCheckModal() } - + const handleKey = (key: { name: string; shift: boolean; ctrl: boolean }) => { - if (state.showWorkflowModal) { + if (state.showCheckModal) { if (key.name === 'escape') { - state.showWorkflowModal = false + state.showCheckModal = false clearModalRows() renderFields() return } - + if (key.name === 'up' || key.name === 'k') { - state.workflowFocusIndex = (state.workflowFocusIndex - 1 + state.workflowItems.length) % state.workflowItems.length - renderWorkflowModal() + state.checkFocusIndex = + (state.checkFocusIndex - 1 + state.checkItems.length) % + state.checkItems.length + renderCheckModal() return } - + if (key.name === 'down' || key.name === 'j') { - state.workflowFocusIndex = (state.workflowFocusIndex + 1) % state.workflowItems.length - renderWorkflowModal() + state.checkFocusIndex = + (state.checkFocusIndex + 1) % state.checkItems.length + renderCheckModal() return } - + if (key.name === 'space') { - const item = state.workflowItems[state.workflowFocusIndex] + const item = state.checkItems[state.checkFocusIndex] if (item) { item.selected = !item.selected - renderWorkflowModal() + renderCheckModal() } return } - + if (key.name === 'return' || key.name === 'enter') { - state.showWorkflowModal = false + state.showCheckModal = false clearModalRows() - const selected = state.workflowItems.filter(w => w.selected).map(w => w.workflow.name) + const selected = state.checkItems + .filter((c) => c.selected) + .map((c) => c.check.name) if (selected.length > 0) { - addWorkflowChecks(selected) + addCheckNames(selected) } else { renderFields() } return } - + return } - + if (key.ctrl && key.name === 'a') { onSave(state.protection) return } - + if (key.name === 'tab') { - state.focusIndex = key.shift + state.focusIndex = key.shift ? (state.focusIndex - 1 + state.fields.length) % state.fields.length : (state.focusIndex + 1) % state.fields.length renderFields() } else if (key.name === 'return' || key.name === 'enter') { const field = state.fields[state.focusIndex] if (!field) return - + if (field.key === 'apply') { onSave(state.protection) return } - + if (field.type === 'boolean') { field.value = !field.value updateProtectionFromField(field) renderFields() - } else if (field.type === 'workflow-select') { - showWorkflowModal() + } else if (field.type === 'check-select') { + showCheckModal() } } else if (key.name === 'escape') { onCancel() } else if (key.name === 'up' || key.name === 'k') { - state.focusIndex = (state.focusIndex - 1 + state.fields.length) % state.fields.length + state.focusIndex = + (state.focusIndex - 1 + state.fields.length) % state.fields.length renderFields() } else if (key.name === 'down' || key.name === 'j') { state.focusIndex = (state.focusIndex + 1) % state.fields.length renderFields() } } - + footer.add(helpText) container.add(title) container.add(scrollContent) container.add(footer) - + const setProtection = (protection: BranchProtectionInput | null) => { - state.protection = protection ? { ...protection } : createDefaultProtection() + state.protection = protection + ? { ...protection } + : createDefaultProtection() state.focusIndex = 0 renderFields() } - + const getProtection = (): BranchProtectionInput => state.protection - + const setRepoInfo = async (owner: string, repo: string) => { state.repoInfo = { owner, repo } - state.workflows = await getRepoWorkflows(owner, repo) + state.availableChecks = await getAvailableChecks(owner, repo) renderFields() } - + renderFields() - - return Object.assign(container, { setProtection, getProtection, setRepoInfo, handleKey }) + + return Object.assign(container, { + setProtection, + getProtection, + setRepoInfo, + handleKey, + }) } function createDefaultProtection(): BranchProtectionInput { diff --git a/src/components/RepoSelector.ts b/src/components/RepoSelector.ts index eec8738..937c3bf 100644 --- a/src/components/RepoSelector.ts +++ b/src/components/RepoSelector.ts @@ -1,6 +1,12 @@ -import { BoxRenderable, TextRenderable, SelectRenderable, SelectRenderableEvents, type CliRenderer } from '@opentui/core' -import type { Repository } from '../types' +import { + BoxRenderable, + type CliRenderer, + SelectRenderable, + SelectRenderableEvents, + TextRenderable, +} from '@opentui/core' import { theme } from '../theme' +import type { Repository } from '../types' export type ReposSelectedCallback = (repos: Repository[]) => void @@ -12,7 +18,7 @@ interface RepoItem { export function createRepoSelector( renderer: CliRenderer, onSelect: ReposSelectedCallback, - onBack: () => void + onBack: () => void, ): BoxRenderable { const container = new BoxRenderable(renderer, { id: 'repo-selector', @@ -22,32 +28,32 @@ export function createRepoSelector( backgroundColor: theme.panelBg, padding: 1, }) - + const headerContainer = new BoxRenderable(renderer, { id: 'repo-header-container', width: '100%', flexDirection: 'row', justifyContent: 'space-between', }) - + const title = new TextRenderable(renderer, { id: 'repo-title', content: 'Select Repositories', fg: theme.accent, }) - + const countText = new TextRenderable(renderer, { id: 'repo-count', content: '0 selected', fg: theme.accentPurple, }) - + const helpText = new TextRenderable(renderer, { id: 'repo-help', content: '↑/↓ Navigate | Space Toggle | Enter Confirm | Esc Back', fg: theme.textMuted, }) - + const select = new SelectRenderable(renderer, { id: 'repo-select', width: '100%', @@ -61,21 +67,23 @@ export function createRepoSelector( showDescription: true, wrapSelection: true, }) - + const state: { repos: RepoItem[] } = { repos: [] } - + const updateCount = () => { const selected = state.repos.filter((r) => r.selected).length countText.content = `${selected} selected` } - + select.on(SelectRenderableEvents.ITEM_SELECTED, (_index, _option) => { - const selectedRepos = state.repos.filter((r) => r.selected).map((r) => r.repo) + const selectedRepos = state.repos + .filter((r) => r.selected) + .map((r) => r.repo) if (selectedRepos.length > 0) { onSelect(selectedRepos) } }) - + const handleKey = (key: { name: string }) => { if (key.name === 'space') { const idx = select.getSelectedIndex() @@ -88,28 +96,31 @@ export function createRepoSelector( onBack() } } - + headerContainer.add(title) headerContainer.add(countText) container.add(headerContainer) container.add(select) container.add(helpText) - + const setRepos = (repos: Repository[]) => { state.repos = repos.map((repo) => ({ repo, selected: false })) updateSelectOptions(select, state.repos) updateCount() select.focus() } - + const blur = () => { select.blur() } - + return Object.assign(container, { setRepos, handleKey, blur }) } -function updateSelectOptions(select: SelectRenderable, items: RepoItem[]): void { +function updateSelectOptions( + select: SelectRenderable, + items: RepoItem[], +): void { select.options = items.map((item) => ({ name: `${item.selected ? '✓' : '○'} ${item.repo.name}`, description: item.repo.private ? 'private' : 'public', @@ -117,7 +128,7 @@ function updateSelectOptions(select: SelectRenderable, items: RepoItem[]): void })) } -export type RepoSelectorWithSet = BoxRenderable & { +export type RepoSelectorWithSet = BoxRenderable & { setRepos: (repos: Repository[]) => void handleKey: (key: { name: string }) => void blur: () => void diff --git a/src/components/TemplateManager.ts b/src/components/TemplateManager.ts index 79cfd0d..1921117 100644 --- a/src/components/TemplateManager.ts +++ b/src/components/TemplateManager.ts @@ -1,7 +1,13 @@ -import { BoxRenderable, TextRenderable, SelectRenderable, SelectRenderableEvents, type CliRenderer } from '@opentui/core' -import type { Template, BranchProtectionInput } from '../types' +import { + BoxRenderable, + type CliRenderer, + SelectRenderable, + SelectRenderableEvents, + TextRenderable, +} from '@opentui/core' import { theme } from '../theme' -import { listTemplates, deleteTemplate } from '../utils/templates' +import type { BranchProtectionInput, Template } from '../types' +import { deleteTemplate, listTemplates } from '../utils/templates' export type TemplateSelectCallback = (protection: BranchProtectionInput) => void export type TemplateCancelCallback = () => void @@ -9,7 +15,7 @@ export type TemplateCancelCallback = () => void export function createTemplateManager( renderer: CliRenderer, onSelect: TemplateSelectCallback, - onCancel: TemplateCancelCallback + onCancel: TemplateCancelCallback, ): BoxRenderable { const container = new BoxRenderable(renderer, { id: 'template-manager', @@ -19,13 +25,13 @@ export function createTemplateManager( backgroundColor: theme.panelBg, padding: 1, }) - + const title = new TextRenderable(renderer, { id: 'template-title', content: 'Templates', fg: theme.accent, }) - + const select = new SelectRenderable(renderer, { id: 'template-select', width: '100%', @@ -39,22 +45,22 @@ export function createTemplateManager( showDescription: true, wrapSelection: true, }) - + const footer = new BoxRenderable(renderer, { id: 'template-footer', width: '100%', flexDirection: 'row', justifyContent: 'space-between', }) - + const helpText = new TextRenderable(renderer, { id: 'template-help', content: 'Enter Load | d Delete | Esc Back', fg: theme.textMuted, }) - + const state: { templates: Template[] } = { templates: [] } - + const loadTemplates = async () => { state.templates = await listTemplates() select.options = state.templates.map((t) => ({ @@ -63,14 +69,14 @@ export function createTemplateManager( value: t, })) } - + select.on(SelectRenderableEvents.ITEM_SELECTED, (_index, option) => { if (option?.value) { const template = option.value as Template onSelect(template.protection) } }) - + const handleKey = async (key: { name: string }) => { if (key.name === 'escape') { onCancel() @@ -83,25 +89,25 @@ export function createTemplateManager( } } } - + footer.add(helpText) container.add(title) container.add(select) container.add(footer) - + const refresh = async () => { await loadTemplates() select.focus() } - + const blur = () => { select.blur() } - + return Object.assign(container, { refresh, handleKey, blur }) } -export type TemplateManagerWithRefresh = BoxRenderable & { +export type TemplateManagerWithRefresh = BoxRenderable & { refresh: () => Promise handleKey: (key: { name: string }) => Promise blur: () => void diff --git a/src/types.ts b/src/types.ts index 08e2aa7..4d89b52 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,7 +105,13 @@ export interface ApplyResult { } export interface AppState { - currentScreen: 'orgs' | 'repos' | 'branches' | 'editor' | 'templates' | 'preview' + currentScreen: + | 'orgs' + | 'repos' + | 'branches' + | 'editor' + | 'templates' + | 'preview' selectedOrg: Organization | null selectedRepos: Repository[] selectedBranch: string | null diff --git a/src/utils/templates.test.ts b/src/utils/templates.test.ts new file mode 100644 index 0000000..ea9e18b --- /dev/null +++ b/src/utils/templates.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdir, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { + createDefaultTemplates, + deleteTemplate, + listTemplates, + loadTemplate, + saveTemplate, +} from './templates' + +const TEST_DIR = join(tmpdir(), `repoprotector-test-${Date.now()}`) + +describe('templates', () => { + beforeEach(async () => { + process.env.REPOPROTECTOR_CONFIG_DIR = TEST_DIR + await mkdir(join(TEST_DIR, 'templates'), { recursive: true }) + }) + + afterEach(async () => { + delete process.env.REPOPROTECTOR_CONFIG_DIR + await rm(TEST_DIR, { recursive: true, force: true }) + }) + + describe('createDefaultTemplates', () => { + test('returns three default templates', () => { + const templates = createDefaultTemplates() + expect(templates.length).toBe(3) + }) + + test('basic template has required fields', () => { + const [basic] = createDefaultTemplates() + expect( + basic?.required_pull_request_reviews?.required_approving_review_count, + ).toBe(1) + expect(basic?.enforce_admins).toBe(false) + expect(basic?.required_status_checks).toBeNull() + }) + + test('strict template enforces admins', () => { + const [, strict] = createDefaultTemplates() + expect(strict?.enforce_admins).toBe(true) + expect( + strict?.required_pull_request_reviews?.required_approving_review_count, + ).toBe(2) + }) + + test('unprotected template allows force pushes', () => { + const [, , unprotected] = createDefaultTemplates() + expect(unprotected?.allow_force_pushes).toBe(true) + expect(unprotected?.required_pull_request_reviews).toBeNull() + }) + }) + + describe('saveTemplate and loadTemplate', () => { + test('saves and loads a template', async () => { + const protection = { + enforce_admins: true, + allow_force_pushes: false, + } + + const saved = await saveTemplate( + 'test-template', + protection, + 'Test description', + ) + expect(saved.name).toBe('test-template') + expect(saved.description).toBe('Test description') + expect(saved.protection.enforce_admins).toBe(true) + + const loaded = await loadTemplate('test-template') + expect(loaded).not.toBeNull() + expect(loaded?.name).toBe('test-template') + expect(loaded?.protection.enforce_admins).toBe(true) + }) + + test('loadTemplate returns null for non-existent template', async () => { + const result = await loadTemplate('non-existent') + expect(result).toBeNull() + }) + + test('preserves created_at on update', async () => { + const protection = { enforce_admins: false } + const first = await saveTemplate('update-test', protection) + + await new Promise((r) => setTimeout(r, 10)) + + const updated = await saveTemplate('update-test', { + enforce_admins: true, + }) + expect(updated.created_at).toBe(first.created_at) + expect(new Date(updated.updated_at).getTime()).toBeGreaterThan( + new Date(first.updated_at).getTime(), + ) + }) + }) + + describe('listTemplates', () => { + test('returns empty array when no templates', async () => { + const templates = await listTemplates() + expect(templates).toEqual([]) + }) + + test('returns templates sorted by name', async () => { + await saveTemplate('zebra', { enforce_admins: true }) + await saveTemplate('alpha', { enforce_admins: false }) + await saveTemplate('middle', { enforce_admins: true }) + + const templates = await listTemplates() + expect(templates.map((t) => t.name)).toEqual(['alpha', 'middle', 'zebra']) + }) + }) + + describe('deleteTemplate', () => { + test('deletes existing template', async () => { + await saveTemplate('to-delete', { enforce_admins: true }) + + const deleted = await deleteTemplate('to-delete') + expect(deleted).toBe(true) + + const loaded = await loadTemplate('to-delete') + expect(loaded).toBeNull() + }) + + test('returns false for non-existent template', async () => { + const result = await deleteTemplate('non-existent') + expect(result).toBe(false) + }) + }) +}) diff --git a/src/utils/templates.ts b/src/utils/templates.ts index b0e85dc..5f296c3 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -1,25 +1,33 @@ +import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' import { homedir } from 'os' import { join } from 'path' -import { mkdir, readdir, readFile, writeFile, unlink } from 'fs/promises' -import type { Template, BranchProtectionInput } from '../types' +import type { BranchProtectionInput, Template } from '../types' -const CONFIG_DIR = join(homedir(), '.config', 'repoprotector') -const TEMPLATES_DIR = join(CONFIG_DIR, 'templates') +function getConfigDir(): string { + return ( + process.env.REPOPROTECTOR_CONFIG_DIR ?? + join(homedir(), '.config', 'repoprotector') + ) +} + +function getTemplatesDir(): string { + return join(getConfigDir(), 'templates') +} async function ensureConfigDir(): Promise { - await mkdir(TEMPLATES_DIR, { recursive: true }) + await mkdir(getTemplatesDir(), { recursive: true }) } export async function listTemplates(): Promise { await ensureConfigDir() - - const files = await readdir(TEMPLATES_DIR) + + const files = await readdir(getTemplatesDir()) const templates: Template[] = [] - + for (const file of files) { if (file.endsWith('.json')) { try { - const content = await readFile(join(TEMPLATES_DIR, file), 'utf-8') + const content = await readFile(join(getTemplatesDir(), file), 'utf-8') const template = JSON.parse(content) as Template templates.push(template) } catch { @@ -27,15 +35,18 @@ export async function listTemplates(): Promise { } } } - + return templates.sort((a, b) => a.name.localeCompare(b.name)) } export async function loadTemplate(name: string): Promise