@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 +125 -0
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/package.json +51 -0
- package/src/__tests__/contrib-gate.test.ts +375 -0
- package/src/config.ts +80 -0
- package/src/helpers.ts +331 -0
- package/src/index.ts +51 -0
- package/src/intercepts.ts +153 -0
- package/src/state.ts +9 -0
- package/src/tools/propose.ts +120 -0
- package/src/tools/start_work.ts +242 -0
- package/src/tools/status.ts +67 -0
- package/src/tools/submit.ts +125 -0
- package/src/types.ts +64 -0
- package/src/validate.ts +53 -0
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
|
+
[](https://www.npmjs.com/package/pi-contrib-gate)
|
|
4
|
+
[](./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
|
+
});
|