@bytesbrains/pi-contrib-gate 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,125 @@
1
+ # Contrib Gate — Agent Usage Guide
2
+
3
+ > You are an AI agent. Use contrib-gate tools instead of raw git commands.
4
+
5
+ ## Golden Rule
6
+
7
+ > **⚠️ DO NOT call `git push`, `git commit`, or `gh pr create` directly.**
8
+ > Use `contrib_start_work(issue_id)` → `contrib_propose()` → `contrib_submit()` instead.
9
+ > This ensures branch naming, conventional commits, and quality gates are enforced.
10
+
11
+ ## ⚡ Issue Linking Required
12
+
13
+ > **Every piece of work MUST be linked to a real Gitea issue.** The gate validates the issue exists,
14
+ > is open, and shows its title so you can verify relevance before starting work.
15
+ >
16
+ > **❌ Blocked:**
17
+ > - Random/fake issue numbers (issue #999 doesn't exist → blocked)
18
+ > - Direct `git checkout -b feat/issue-42` bypassing contrib_start_work
19
+ > - Starting work on a closed issue
20
+ >
21
+ > **⚠️ Warned:**
22
+ > - Issue assigned to someone else
23
+ >
24
+ > **✅ Allowed:**
25
+ > - Issue exists + is open → branch created, work can begin
26
+ >
27
+ > **Rework on an existing PR?** If you're already on a feature branch (e.g., `feat/some-custom-name`),
28
+ > calling `contrib_start_work(issue_id="42")` will link the issue without creating a new branch —
29
+ > you just continue working on the existing PR.
30
+ >
31
+ > **Resuming work?** If you return to a branch like `feat/issue-42`, the issue is auto-detected
32
+ > and you can start working immediately — no need to call `contrib_start_work` again.
33
+
34
+ ## Workflow
35
+
36
+ ```
37
+ contrib_start_work(issue_id="42") ← creates feat/issue-42 branch
38
+
39
+
40
+ [make code changes — edit, write, bash for tests]
41
+
42
+
43
+ contrib_propose(message="...") ← validates + stages + quality-checks + commits
44
+
45
+
46
+ contrib_submit(title="...") ← pushes + creates PR
47
+
48
+
49
+ contrib_status() ← verify everything looks good
50
+ ```
51
+
52
+ ## When Things Go Wrong
53
+
54
+ | Problem | Solution |
55
+ |---|---|
56
+ | Quality gate failed | Fix the errors listed, then retry `contrib_propose()` |
57
+ | Branch naming wrong | `git checkout -b feat/correct-name` and start from `contrib_propose()` |
58
+ | Need to bypass a gate | Ask the human for confirmation — gates can be overridden |
59
+ | Push blocked | Use `contrib_submit()` instead of `git push` |
60
+ | Write/edit blocked: "No Gitea issue linked" | You forgot `contrib_start_work(issue_id)`. Call it first with a valid issue number. |
61
+ | contrib_start_work blocked: "Issue #X does not exist" | Issue number is fake or wrong. Use `project_list_issues()` to find real issues. |
62
+ | contrib_start_work blocked: "Issue #X is closed" | Pick an open issue or reopen it. Use `project_list_issues(state="open")`. |
63
+ | contrib_start_work blocked: branch creation | You tried `git checkout -b feat/issue-42` directly. Use `contrib_start_work(issue_id)` instead. |
64
+ | contrib_propose blocked: no issue | Run `contrib_start_work(issue_id)` to link an issue, then retry. |
65
+
66
+ ## Conventional Commits
67
+
68
+ Your commit messages must follow this format:
69
+
70
+ ```
71
+ type(scope): subject
72
+
73
+ body (optional)
74
+
75
+ Refs: #issue-id
76
+ ```
77
+
78
+ Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `perf`, `ci`, `build`, `revert`
79
+
80
+ Examples:
81
+ - `feat(backup): add Firebase volume backup script`
82
+ - `fix(ci): resolve stale info error in sync workflow`
83
+ - `chore(deps): update playwright to v1.52`
84
+
85
+ ## Best Practice Guidance
86
+
87
+ The contrib-gate can inject best-practice guidance into your commit workflow to encourage short, frequent, and atomic commits. These are **soft warnings** — they appear in `contrib_propose()` output but **never block** the commit.
88
+
89
+ ### Config (`commits.bestPractices.*`)
90
+
91
+ Add these keys to `.contribrc.yml`:
92
+
93
+ | Key | Default | Description |
94
+ |---|---|---|
95
+ | `commits.bestPractices.shortFrequentCommits` | `true` | When enabled, guidance text is shown after each commit |
96
+ | `commits.bestPractices.maxLinesPerCommit` | `150` | Soft-warn if a commit exceeds this many lines added. Set to `0` to disable. |
97
+ | `commits.bestPractices.requireAtomic` | `true` | Soft-warn if commit touches many unrelated directories |
98
+ | `commits.bestPractices.maxUnrelatedDirs` | `3` | Threshold for the "non-atomic" heuristic |
99
+ | `commits.bestPractices.guidanceText` | (see below) | Custom guidance injected into agent context |
100
+
101
+ Default guidance text:
102
+ - Commit after every logical unit of work.
103
+ - Aim for < 150 lines per commit.
104
+ - Each commit should do one thing — keep changes atomic.
105
+
106
+ ### What You'll See
107
+
108
+ When `shortFrequentCommits` is enabled, every `contrib_propose()` output includes a guidance section:
109
+
110
+ ```
111
+ ✅ Changes committed (abc12345)
112
+ Branch: feat/my-feature
113
+ Message: feat(api): add endpoint
114
+
115
+ 📋 Best Practice Guidance:
116
+ • Commit after every logical unit of work.
117
+ • Aim for < 150 lines per commit.
118
+ • Each commit should do one thing — keep changes atomic.
119
+
120
+ --- Soft Warnings (non-blocking) ---
121
+ ⚠️ Commit size (230 lines) exceeds best-practice threshold (150 lines).
122
+ Consider splitting into smaller, more frequent commits.
123
+
124
+ Next: contrib_submit(title, body) to push and create PR.
125
+ ```
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nandal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # Contrib Gate for Pi
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pi-contrib-gate)](https://www.npmjs.com/package/pi-contrib-gate)
4
+ [![license](https://img.shields.io/npm/l/pi-contrib-gate)](./LICENSE)
5
+
6
+ > Contribution gateway for AI agents — enforce branch naming, conventional commits, pre-commit quality gates, and PR automation. **Agents don't call `git push` — they call `contrib_submit()`.**
7
+
8
+ > ⚡ **Every piece of work must be linked to a Gitea issue.** The gate blocks file modifications, commits, and PR creation until an issue is linked via `contrib_start_work(issue_id)`.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pi install npm:pi-contrib-gate
14
+ ```
15
+
16
+ ## Tools
17
+
18
+ | Tool | What it does |
19
+ |---|---|
20
+ | `contrib_start_work(issue_id)` | Create properly named branch, link to issue |
21
+ | `contrib_propose(message, files)` | Validate, stage, quality-check, commit |
22
+ | `contrib_submit(title, body)` | Push, create PR, return URL |
23
+ | `contrib_status()` | Show branch, commits, changes, PR status |
24
+
25
+ ## Safety Intercepts
26
+
27
+ The gate **passively monitors** all `bash` tool calls and:
28
+
29
+ - ⛔ Blocks `write`/`edit` operations when no Gitea issue is linked (reminds agent to call `contrib_start_work(issue_id)` first)
30
+ - ⛔ Blocks `write`/`edit` on protected branches (`main`, `dev`, `production`)
31
+ - ⛔ Blocks `contrib_propose()` and `contrib_submit()` when no issue is linked
32
+ - ⚠️ Warns on `git push --force`
33
+ - ⚠️ Warns on non-conventional commit messages
34
+ - ⚠️ Soft-warns on feature branches without a recognized issue ID
35
+ - All blocks can be overridden with user confirmation
36
+
37
+ ## Quality Gates
38
+
39
+ Every `contrib_propose()` runs:
40
+
41
+ | Check | Default | Config |
42
+ |---|---|---|
43
+ | Conventional commit format | ✅ | `commits.convention` |
44
+ | Max files changed (20) | ✅ | `quality.maxFilesChanged` |
45
+ | Max lines added (500) | ✅ | `quality.maxLinesAdded` |
46
+ | TypeScript check (`tsc --noEmit`) | ✅ | `quality.typeCheck` |
47
+ | Lint check (`npm run lint`) | ✅ | `quality.lint` |
48
+ | Doctor audit (god file detection) | ✅ | `quality.doctorAudit` |
49
+
50
+ ## Configuration
51
+
52
+ Create `.contribrc.yml` in your project root (created automatically on first use with defaults):
53
+
54
+ ```yaml
55
+ branches.featPattern: feat/
56
+ branches.fixPattern: fix/
57
+ branches.chorePattern: chore/
58
+ commits.convention: conventional
59
+ commits.maxSubjectLength: 72
60
+ quality.maxFilesChanged: 20
61
+ quality.maxLinesAdded: 500
62
+ quality.lint: true
63
+ quality.typeCheck: true
64
+ quality.doctorAudit: true
65
+ ```
66
+
67
+ ## Example Workflow
68
+
69
+ ```
70
+ → contrib_start_work(issue_id="7", type="feat")
71
+ ✅ Work started on feat/issue-7
72
+
73
+ → [agent makes code changes...]
74
+
75
+ → contrib_propose(message="feat(backup): add Firebase volume backup script", body="Implements daily pg_dump + tar + upload.")
76
+ ✅ Changes committed (a1b2c3d)
77
+
78
+ → contrib_submit(title="feat: automated volume backups to Firebase Storage")
79
+ 🎉 PR created: http://localhost:3001/factory/wrok.in/pulls/47
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT © [nandal](https://github.com/nandal)
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@bytesbrains/pi-contrib-gate",
3
+ "version": "1.6.1",
4
+ "description": "Contribution gateway for AI agents \u2014 enforce branch naming, conventional commits, pre-commit quality gates, and PR automation.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "governance",
9
+ "git",
10
+ "conventional-commits",
11
+ "ci",
12
+ "devops"
13
+ ],
14
+ "author": "nandal <nandal@users.noreply.github.com>",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/nandal/pi-ext",
18
+ "directory": "contrib-gate"
19
+ },
20
+ "homepage": "https://github.com/nandal/pi-ext/tree/main/contrib-gate",
21
+ "bugs": {
22
+ "url": "https://github.com/nandal/pi-ext/issues"
23
+ },
24
+ "license": "MIT",
25
+ "main": "./src/index.ts",
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "files": [
30
+ "src/",
31
+ "README.md",
32
+ "AGENTS.md",
33
+ "LICENSE"
34
+ ],
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "typebox": "*"
38
+ },
39
+ "pi": {
40
+ "extensions": [
41
+ "./src/index.ts"
42
+ ]
43
+ },
44
+ "scripts": {
45
+ "test": "vitest run",
46
+ "test:watch": "vitest"
47
+ },
48
+ "devDependencies": {
49
+ "vitest": "^2.1.9"
50
+ }
51
+ }
@@ -0,0 +1,375 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { DEFAULT_CONFIG, BEST_PRACTICES_DEFAULTS } from "../types";
3
+ import { loadConfig } from "../config";
4
+ import { validateBranchName, validateConventionalCommit, runQualityGate } from "../validate";
5
+ import {
6
+ exec, currentBranch,
7
+ isClean, isMergeInProgress, isRebaseInProgress, isConflictInProgress,
8
+ scanForConflictMarkers, getStagedStats, countUnrelatedDirs,
9
+ extractIssueFromBranch,
10
+ } from "../helpers";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as os from "node:os";
14
+
15
+ // ═══════════════════════════════════════
16
+ // Config
17
+ // ═══════════════════════════════════════
18
+ describe("ContribConfig", () => {
19
+ it("returns defaults when no config file", () => {
20
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "contrib-test-"));
21
+ const config = loadConfig(tmp);
22
+ expect(config.branches.featPattern).toBe("feat/");
23
+ expect(config.commits.convention).toBe("conventional");
24
+ expect(config.quality.maxFilesChanged).toBe(20);
25
+ expect(config.quality.maxLinesAdded).toBe(500);
26
+ fs.rmSync(tmp, { recursive: true, force: true });
27
+ });
28
+
29
+ it("parses contribrc.yml", () => {
30
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "contrib-test-"));
31
+ fs.writeFileSync(path.join(tmp, ".contribrc.yml"), [
32
+ "branches.featPattern: feature/",
33
+ "commits.convention: simple",
34
+ "commits.maxSubjectLength: 100",
35
+ "quality.maxFilesChanged: 50",
36
+ "quality.lint: false",
37
+ ].join("\n"));
38
+ const config = loadConfig(tmp);
39
+ expect(config.branches.featPattern).toBe("feature/");
40
+ expect(config.commits.convention).toBe("simple");
41
+ expect(config.commits.maxSubjectLength).toBe(100);
42
+ expect(config.quality.maxFilesChanged).toBe(50);
43
+ expect(config.quality.lint).toBe(false);
44
+ fs.rmSync(tmp, { recursive: true, force: true });
45
+ });
46
+
47
+ it("handles missing config gracefully", () => {
48
+ const config = loadConfig("/nonexistent/path");
49
+ expect(config.branches.featPattern).toBe("feat/");
50
+ });
51
+
52
+ it("loads bestPractices defaults", () => {
53
+ const config = loadConfig("/nonexistent/path");
54
+ expect(config.commits.bestPractices.shortFrequentCommits).toBe(true);
55
+ expect(config.commits.bestPractices.maxLinesPerCommit).toBe(150);
56
+ expect(config.commits.bestPractices.requireAtomic).toBe(true);
57
+ expect(config.commits.bestPractices.maxUnrelatedDirs).toBe(3);
58
+ expect(config.commits.bestPractices.guidanceText).toEqual(BEST_PRACTICES_DEFAULTS.guidanceText);
59
+ });
60
+
61
+ it("parses bestPractices config keys", () => {
62
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "contrib-test-"));
63
+ fs.writeFileSync(path.join(tmp, ".contribrc.yml"), [
64
+ "commits.bestPractices.shortFrequentCommits: false",
65
+ "commits.bestPractices.maxLinesPerCommit: 200",
66
+ "commits.bestPractices.requireAtomic: false",
67
+ "commits.bestPractices.maxUnrelatedDirs: 5",
68
+ 'commits.bestPractices.guidanceText: "Be thoughtful | Keep it small | Test everything"',
69
+ ].join("\n"));
70
+ const config = loadConfig(tmp);
71
+ expect(config.commits.bestPractices.shortFrequentCommits).toBe(false);
72
+ expect(config.commits.bestPractices.maxLinesPerCommit).toBe(200);
73
+ expect(config.commits.bestPractices.requireAtomic).toBe(false);
74
+ expect(config.commits.bestPractices.maxUnrelatedDirs).toBe(5);
75
+ expect(config.commits.bestPractices.guidanceText).toEqual(["Be thoughtful", "Keep it small", "Test everything"]);
76
+ fs.rmSync(tmp, { recursive: true, force: true });
77
+ });
78
+
79
+ it("backward-compatible with existing configs (no bestPractices keys)", () => {
80
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "contrib-test-"));
81
+ fs.writeFileSync(path.join(tmp, ".contribrc.yml"), [
82
+ "quality.maxLinesAdded: 600",
83
+ "quality.doctorAudit: false",
84
+ ].join("\n"));
85
+ const config = loadConfig(tmp);
86
+ // Existing keys still work
87
+ expect(config.quality.maxLinesAdded).toBe(600);
88
+ expect(config.quality.doctorAudit).toBe(false);
89
+ // Best practices get defaults
90
+ expect(config.commits.bestPractices.maxLinesPerCommit).toBe(150);
91
+ expect(config.commits.bestPractices.shortFrequentCommits).toBe(true);
92
+ expect(config.commits.bestPractices.requireAtomic).toBe(true);
93
+ fs.rmSync(tmp, { recursive: true, force: true });
94
+ });
95
+ });
96
+
97
+ // ═══════════════════════════════════════
98
+ // Validation
99
+ // ═══════════════════════════════════════
100
+ describe("validateBranchName", () => {
101
+ it("accepts feat/* branches", () => {
102
+ expect(validateBranchName("feat/issue-42").ok).toBe(true);
103
+ expect(validateBranchName("feat/add-login").ok).toBe(true);
104
+ });
105
+
106
+ it("accepts fix/* branches", () => {
107
+ expect(validateBranchName("fix/bug-123").ok).toBe(true);
108
+ });
109
+
110
+ it("accepts chore/* branches", () => {
111
+ expect(validateBranchName("chore/update-deps").ok).toBe(true);
112
+ });
113
+
114
+ it("rejects invalid branch names", () => {
115
+ expect(validateBranchName("main").ok).toBe(false);
116
+ expect(validateBranchName("dev").ok).toBe(false);
117
+ expect(validateBranchName("feature/something").ok).toBe(false);
118
+ expect(validateBranchName("hotfix/urgent").ok).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe("validateConventionalCommit", () => {
123
+ const config = { ...DEFAULT_CONFIG, commits: { ...DEFAULT_CONFIG.commits, maxSubjectLength: 72 } };
124
+
125
+ it("accepts valid conventional commits", () => {
126
+ expect(validateConventionalCommit("feat: add login", config).ok).toBe(true);
127
+ expect(validateConventionalCommit("fix(api): resolve null pointer", config).ok).toBe(true);
128
+ expect(validateConventionalCommit("chore(deps): update packages", config).ok).toBe(true);
129
+ });
130
+
131
+ it("accepts all valid types", () => {
132
+ for (const type of ["feat", "fix", "chore", "docs", "style", "refactor", "test", "perf", "ci", "build", "revert"]) {
133
+ expect(validateConventionalCommit(`${type}: something`, config).ok).toBe(true);
134
+ }
135
+ });
136
+
137
+ it("rejects non-conventional messages", () => {
138
+ expect(validateConventionalCommit("added login", config).ok).toBe(false);
139
+ expect(validateConventionalCommit("Update stuff", config).ok).toBe(false);
140
+ expect(validateConventionalCommit("WIP", config).ok).toBe(false);
141
+ });
142
+
143
+ it("rejects subject too long", () => {
144
+ const long = "feat: " + "x".repeat(70);
145
+ expect(validateConventionalCommit(long, config).ok).toBe(false);
146
+ });
147
+
148
+ it("handles multiline messages", () => {
149
+ const msg = "feat: add feature\n\nExtended body text here.";
150
+ expect(validateConventionalCommit(msg, config).ok).toBe(true);
151
+ });
152
+ });
153
+
154
+ // ═══════════════════════════════════════
155
+ // Helpers
156
+ // ═══════════════════════════════════════
157
+ describe("exec helper", () => {
158
+ it("returns ok true for successful command", () => {
159
+ const r = exec("echo hello");
160
+ expect(r.ok).toBe(true);
161
+ expect(r.stdout).toBe("hello");
162
+ });
163
+
164
+ it("returns ok false for failed command", () => {
165
+ const r = exec("nonexistent-command-12345 2>/dev/null");
166
+ expect(r.ok).toBe(false);
167
+ });
168
+ });
169
+
170
+ describe("currentBranch", () => {
171
+ it("returns current branch name", () => {
172
+ const branch = currentBranch(process.cwd());
173
+ expect(typeof branch).toBe("string");
174
+ expect(branch.length).toBeGreaterThan(0);
175
+ });
176
+ });
177
+
178
+ describe("isClean", () => {
179
+ it("checks working tree status", () => {
180
+ const clean = isClean(process.cwd());
181
+ expect(typeof clean).toBe("boolean");
182
+ });
183
+ });
184
+
185
+ describe("getStagedStats", () => {
186
+ it("returns empty when nothing staged", () => {
187
+ const stats = getStagedStats(process.cwd());
188
+ expect(stats.files).toEqual([]);
189
+ expect(stats.linesAdded).toBe(0);
190
+ });
191
+
192
+ it("returns files and line counts for staged changes", () => {
193
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "contrib-stats-"));
194
+ exec("git init && git config user.email test@test && git config user.name test", tmp);
195
+ fs.writeFileSync(path.join(tmp, "a.txt"), "hello\nworld\n");
196
+ exec("git add a.txt && git commit -m init", tmp);
197
+ fs.writeFileSync(path.join(tmp, "a.txt"), "hello\nworld\nnew line\n");
198
+ exec("git add a.txt", tmp);
199
+ const stats = getStagedStats(tmp);
200
+ expect(stats.files).toContain("a.txt");
201
+ expect(stats.linesAdded).toBeGreaterThan(0);
202
+ fs.rmSync(tmp, { recursive: true, force: true });
203
+ });
204
+ });
205
+
206
+ describe("countUnrelatedDirs", () => {
207
+ it("counts distinct directories from file paths", () => {
208
+ const files = ["src/a.ts", "src/b.ts", "tests/c.test.ts"];
209
+ expect(countUnrelatedDirs(files)).toBe(2);
210
+ });
211
+
212
+ it("returns 1 for single-directory changes", () => {
213
+ const files = ["src/index.ts", "src/config.ts", "src/types.ts"];
214
+ // All in src/, but 2 subdirectories: src/* (all same level, no subdirs)
215
+ // All have just "src" as top dir, subdirs would be "src" only
216
+ const result = countUnrelatedDirs(files);
217
+ expect(result).toBe(1);
218
+ });
219
+
220
+ it("detects deeply nested unrelated subdirectories", () => {
221
+ const files = ["contrib-gate/src/a.ts", "contrib-gate/test/b.ts", "ci-gate/src/c.ts"];
222
+ expect(countUnrelatedDirs(files)).toBe(3);
223
+ });
224
+
225
+ it("handles empty file list", () => {
226
+ expect(countUnrelatedDirs([])).toBe(0);
227
+ });
228
+ });
229
+
230
+ // ═══════════════════════════════════════
231
+ // Issue extraction from branches
232
+ // ═══════════════════════════════════════
233
+ describe("extractIssueFromBranch", () => {
234
+ it("extracts issue ID from feat/issue-N", () => {
235
+ expect(extractIssueFromBranch("feat/issue-42")).toBe("42");
236
+ expect(extractIssueFromBranch("feat/issue-7")).toBe("7");
237
+ expect(extractIssueFromBranch("feat/issue-123")).toBe("123");
238
+ });
239
+
240
+ it("extracts issue ID from fix/issue-N", () => {
241
+ expect(extractIssueFromBranch("fix/issue-99")).toBe("99");
242
+ });
243
+
244
+ it("extracts issue ID from chore/issue-N", () => {
245
+ expect(extractIssueFromBranch("chore/issue-1")).toBe("1");
246
+ });
247
+
248
+ it("extracts issue ID from bare issue-N", () => {
249
+ expect(extractIssueFromBranch("issue-42")).toBe("42");
250
+ });
251
+
252
+ it("returns null for branches without issue ID", () => {
253
+ expect(extractIssueFromBranch("feat/add-login")).toBe(null);
254
+ expect(extractIssueFromBranch("fix/bug-fix")).toBe(null);
255
+ expect(extractIssueFromBranch("main")).toBe(null);
256
+ expect(extractIssueFromBranch("dev")).toBe(null);
257
+ expect(extractIssueFromBranch("random-branch")).toBe(null);
258
+ });
259
+
260
+ it("returns null for issue-N in middle of branch name", () => {
261
+ expect(extractIssueFromBranch("feat/issue-42-hotfix")).toBe(null);
262
+ expect(extractIssueFromBranch("prefix-issue-42")).toBe(null);
263
+ });
264
+ });
265
+
266
+ // ═══════════════════════════════════════
267
+ // Quality gates
268
+ // ═══════════════════════════════════════
269
+ describe("runQualityGate", () => {
270
+ it("checks changed files count", () => {
271
+ // In a test env without staged changes, this should pass
272
+ const config = { ...DEFAULT_CONFIG, quality: { ...DEFAULT_CONFIG.quality, typeCheck: false, lint: false } };
273
+ const result = runQualityGate(process.cwd(), config);
274
+ expect(typeof result.ok).toBe("boolean");
275
+ });
276
+
277
+ it("fails when staged files contain conflict markers", () => {
278
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "contrib-qg-conflict-"));
279
+ exec("git init && git config user.email test@test && git config user.name test", tmp);
280
+
281
+ // Seed initial commit
282
+ fs.writeFileSync(path.join(tmp, "file.txt"), "clean\n");
283
+ exec("git add file.txt && git commit -m init", tmp);
284
+
285
+ // Write conflict markers and stage
286
+ fs.writeFileSync(path.join(tmp, "file.txt"), "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> dev\n");
287
+ exec("git add file.txt", tmp);
288
+
289
+ const config = { ...DEFAULT_CONFIG, quality: { ...DEFAULT_CONFIG.quality, typeCheck: false, lint: false } };
290
+ const result = runQualityGate(tmp, config);
291
+ expect(result.ok).toBe(false);
292
+ if (!result.ok) {
293
+ expect(result.errors.some((e: string) => e.includes("conflict markers"))).toBe(true);
294
+ }
295
+
296
+ fs.rmSync(tmp, { recursive: true, force: true });
297
+ });
298
+ });
299
+
300
+ // ═══════════════════════════════════════
301
+ // Merge conflict helpers
302
+ // ═══════════════════════════════════════
303
+ describe("isMergeInProgress", () => {
304
+ it("returns false when no merge in progress", () => {
305
+ expect(isMergeInProgress(process.cwd())).toBe(false);
306
+ });
307
+ });
308
+
309
+ describe("isRebaseInProgress", () => {
310
+ it("returns false when no rebase in progress", () => {
311
+ expect(isRebaseInProgress(process.cwd())).toBe(false);
312
+ });
313
+ });
314
+
315
+ describe("isConflictInProgress", () => {
316
+ it("returns false when no conflict in progress", () => {
317
+ expect(isConflictInProgress(process.cwd())).toBe(false);
318
+ });
319
+ });
320
+
321
+ // ═══════════════════════════════════════
322
+ // Conflict marker scanning
323
+ // ═══════════════════════════════════════
324
+ describe("scanForConflictMarkers", () => {
325
+ it("returns empty when no conflict markers in staged files", () => {
326
+ const result = scanForConflictMarkers(process.cwd());
327
+ expect(result).toEqual([]);
328
+ });
329
+
330
+ it("detects conflict markers in a staged file (simulated via git show)", () => {
331
+ // Create a temp repo to test conflict detection properly
332
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "contrib-conflict-"));
333
+ exec("git init && git config user.email test@test && git config user.name test", tmp);
334
+
335
+ // Create a file with conflict markers
336
+ const filePath = path.join(tmp, "test.txt");
337
+ fs.writeFileSync(filePath, [
338
+ "line before",
339
+ "<<<<<<< HEAD",
340
+ "our change",
341
+ "=======",
342
+ "their change",
343
+ ">>>>>>> dev",
344
+ "line after",
345
+ ].join("\n"));
346
+
347
+ // Stage and commit a clean version first (need initial commit for git show :0:)
348
+ fs.writeFileSync(filePath, "clean content\n");
349
+ exec(`git add test.txt && git commit -m "init"`, tmp);
350
+
351
+ // Now write conflict markers and stage
352
+ fs.writeFileSync(filePath, "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> dev\n");
353
+ exec("git add test.txt", tmp);
354
+
355
+ const result = scanForConflictMarkers(tmp);
356
+ expect(result).toContain("test.txt");
357
+
358
+ fs.rmSync(tmp, { recursive: true, force: true });
359
+ });
360
+
361
+ it("returns empty for clean staged files", () => {
362
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "contrib-clean-"));
363
+ exec("git init && git config user.email test@test && git config user.name test", tmp);
364
+
365
+ fs.writeFileSync(path.join(tmp, "clean.txt"), "no conflicts here\n");
366
+ exec("git add clean.txt && git commit -m init", tmp);
367
+ fs.writeFileSync(path.join(tmp, "clean.txt"), "updated clean content\n");
368
+ exec("git add clean.txt", tmp);
369
+
370
+ const result = scanForConflictMarkers(tmp);
371
+ expect(result).toEqual([]);
372
+
373
+ fs.rmSync(tmp, { recursive: true, force: true });
374
+ });
375
+ });