@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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import { exec, currentBranch, getStagedStats, countUnrelatedDirs, shellEscape, getLinkedIssueId } from "../helpers";
|
|
5
|
+
import { validateBranchName, validateConventionalCommit, runQualityGate } from "../validate";
|
|
6
|
+
|
|
7
|
+
export const proposeTool = {
|
|
8
|
+
name: "contrib_propose" as const,
|
|
9
|
+
label: "Propose Changes",
|
|
10
|
+
description: "Stage files, validate conventional commit, run quality checks, and commit.",
|
|
11
|
+
parameters: Type.Object({
|
|
12
|
+
message: Type.String({ description: "Conventional commit message: type(scope): subject" }),
|
|
13
|
+
body: Type.Optional(Type.String({ description: "Extended commit body with details" })),
|
|
14
|
+
}),
|
|
15
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, ctx: ExtensionContext) {
|
|
16
|
+
const config = loadConfig(ctx.cwd);
|
|
17
|
+
const branch = currentBranch(ctx.cwd);
|
|
18
|
+
|
|
19
|
+
// ── Require a linked issue ──
|
|
20
|
+
const issueId = getLinkedIssueId(ctx.cwd);
|
|
21
|
+
if (!issueId) {
|
|
22
|
+
return {
|
|
23
|
+
content: [{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: [
|
|
26
|
+
`❌ No Gitea issue linked.`,
|
|
27
|
+
``,
|
|
28
|
+
`Before committing, link your work to an issue with:`,
|
|
29
|
+
` contrib_start_work(issue_id)`,
|
|
30
|
+
``,
|
|
31
|
+
`This ensures commits and PRs are traceable to the correct issue.`,
|
|
32
|
+
].join("\n"),
|
|
33
|
+
}],
|
|
34
|
+
isError: true,
|
|
35
|
+
details: {},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const branchCheck = validateBranchName(branch);
|
|
40
|
+
if (!branchCheck.ok) return { content: [{ type: "text", text: branchCheck.error }], isError: true, details: {} };
|
|
41
|
+
|
|
42
|
+
if (config.commits.convention === "conventional") {
|
|
43
|
+
const msgCheck = validateConventionalCommit(params.message, config);
|
|
44
|
+
if (!msgCheck.ok) return { content: [{ type: "text", text: msgCheck.error }], isError: true, details: {} };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const add = exec("git add -A", ctx.cwd);
|
|
48
|
+
if (!add.ok) return { content: [{ type: "text", text: `Failed to stage files: ${add.stderr}` }], isError: true, details: {} };
|
|
49
|
+
|
|
50
|
+
const quality = runQualityGate(ctx.cwd, config);
|
|
51
|
+
if (!quality.ok) {
|
|
52
|
+
return { content: [{ type: "text", text: `❌ Quality gate failed:\n\n${quality.errors.map((e: string) => ` - ${e}`).join("\n")}` }], isError: true, details: { errors: quality.errors } };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Best-practice soft warnings (non-blocking) ──
|
|
56
|
+
const bestPractices = config.commits.bestPractices;
|
|
57
|
+
const warnings: string[] = [];
|
|
58
|
+
|
|
59
|
+
if (bestPractices.maxLinesPerCommit > 0) {
|
|
60
|
+
const { linesAdded } = getStagedStats(ctx.cwd);
|
|
61
|
+
if (linesAdded > bestPractices.maxLinesPerCommit) {
|
|
62
|
+
warnings.push(
|
|
63
|
+
`⚠️ Commit size (${linesAdded} lines) exceeds best-practice threshold (${bestPractices.maxLinesPerCommit} lines).`,
|
|
64
|
+
` Consider splitting into smaller, more frequent commits.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (bestPractices.requireAtomic) {
|
|
70
|
+
const { files } = getStagedStats(ctx.cwd);
|
|
71
|
+
const dirCount = countUnrelatedDirs(files);
|
|
72
|
+
if (dirCount > bestPractices.maxUnrelatedDirs) {
|
|
73
|
+
warnings.push(
|
|
74
|
+
`⚠️ This commit touches ${dirCount} unrelated directories (threshold: ${bestPractices.maxUnrelatedDirs}).`,
|
|
75
|
+
` It may not be atomic. Each commit should do one logical thing.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let fullMessage = params.message;
|
|
81
|
+
if (params.body) fullMessage += `\n\n${params.body}`;
|
|
82
|
+
if (issueId) fullMessage += `\n\nRefs: #${issueId}`;
|
|
83
|
+
|
|
84
|
+
const commit = exec(`git commit -m ${shellEscape(fullMessage)}`, ctx.cwd);
|
|
85
|
+
if (!commit.ok) return { content: [{ type: "text", text: `Commit failed: ${commit.stderr}` }], isError: true, details: {} };
|
|
86
|
+
|
|
87
|
+
const hash = exec("git rev-parse HEAD", ctx.cwd).stdout.slice(0, 8);
|
|
88
|
+
(globalThis as any).__contrib_lastHash = hash;
|
|
89
|
+
|
|
90
|
+
// Build output message with best-practice guidance and warnings
|
|
91
|
+
const outputLines: string[] = [
|
|
92
|
+
`✅ Changes committed (${hash})`,
|
|
93
|
+
` Branch: ${branch}`,
|
|
94
|
+
` Message: ${params.message}`,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// Inject best-practice guidance when enabled
|
|
98
|
+
if (bestPractices.shortFrequentCommits && bestPractices.guidanceText.length > 0) {
|
|
99
|
+
outputLines.push(``, `📋 Best Practice Guidance:`);
|
|
100
|
+
for (const line of bestPractices.guidanceText) {
|
|
101
|
+
outputLines.push(` • ${line}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Append soft warnings
|
|
106
|
+
if (warnings.length > 0) {
|
|
107
|
+
outputLines.push(``, `--- Soft Warnings (non-blocking) ---`);
|
|
108
|
+
for (const w of warnings) {
|
|
109
|
+
outputLines.push(w);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
outputLines.push(``, `Next: contrib_submit(title, body) to push and create PR.`);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: outputLines.join("\n") }],
|
|
117
|
+
details: { branch, commit: hash, message: params.message, warnings: warnings.length > 0 ? warnings : undefined },
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import { exec, isClean, shellEscape, getLinkedIssueId, remoteBranchExists, checkBranchPRState, resolveGitea, giteaApi } from "../helpers";
|
|
5
|
+
|
|
6
|
+
export const startWorkTool = {
|
|
7
|
+
name: "contrib_start_work" as const,
|
|
8
|
+
label: "Start Work",
|
|
9
|
+
description: "Link work to a Gitea issue. Creates a feature branch if not already on one; otherwise links the issue to the current branch (e.g., for rework on an existing PR).",
|
|
10
|
+
parameters: Type.Object({
|
|
11
|
+
issue_id: Type.String({ description: "Issue number or ID to link this work to" }),
|
|
12
|
+
type: Type.Optional(Type.String({ description: "Branch type: feat, fix, or chore (default: feat)" })),
|
|
13
|
+
}),
|
|
14
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, ctx: ExtensionContext) {
|
|
15
|
+
const config = loadConfig(ctx.cwd);
|
|
16
|
+
const branchType = params.type || "feat";
|
|
17
|
+
const issueId = params.issue_id.replace(/^#/, "");
|
|
18
|
+
const currentBr = exec("git branch --show-current", ctx.cwd).stdout;
|
|
19
|
+
const isFeatureBranch = /^(feat|fix|chore)\//.test(currentBr);
|
|
20
|
+
|
|
21
|
+
if (!["feat", "fix", "chore"].includes(branchType)) {
|
|
22
|
+
return { content: [{ type: "text", text: `Invalid branch type: "${branchType}". Use: feat, fix, or chore.` }], isError: true, details: {} };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Validate issue exists on Gitea ──
|
|
26
|
+
if (config.requireIssueValidation) {
|
|
27
|
+
const opts = resolveGitea(ctx.cwd);
|
|
28
|
+
if (!opts.repo) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{
|
|
31
|
+
type: "text",
|
|
32
|
+
text: [
|
|
33
|
+
`⚠️ Could not resolve Gitea repo to validate issue #${issueId}.`,
|
|
34
|
+
``,
|
|
35
|
+
`Set requireIssueValidation: false in .contribrc.yml to skip validation.`,
|
|
36
|
+
].join("\n"),
|
|
37
|
+
}],
|
|
38
|
+
isError: true,
|
|
39
|
+
details: { issueId },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const r = await giteaApi(`/issues/${issueId}`, "GET", null, opts);
|
|
44
|
+
if (!r.ok || !r.data) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{
|
|
47
|
+
type: "text",
|
|
48
|
+
text: [
|
|
49
|
+
`❌ Issue #${issueId} does not exist on Gitea.`,
|
|
50
|
+
``,
|
|
51
|
+
`You must link work to a real Gitea issue.`,
|
|
52
|
+
`Use project_list_issues() to see available issues,`,
|
|
53
|
+
`or project_create_issue() to create a new one.`,
|
|
54
|
+
].join("\n"),
|
|
55
|
+
}],
|
|
56
|
+
isError: true,
|
|
57
|
+
details: { issueId },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const issue = r.data as Record<string, unknown>;
|
|
62
|
+
const issueTitle = issue.title || "(no title)";
|
|
63
|
+
const issueState = issue.state || "unknown";
|
|
64
|
+
const issueAssignee = (issue.assignee as any)?.login || "";
|
|
65
|
+
const issueLabels = Array.isArray(issue.labels) ? (issue.labels as any[]).map((l: any) => l.name).join(", ") : "";
|
|
66
|
+
|
|
67
|
+
const warnings: string[] = [];
|
|
68
|
+
|
|
69
|
+
// Block: issue is closed
|
|
70
|
+
if (issueState !== "open") {
|
|
71
|
+
return {
|
|
72
|
+
content: [{
|
|
73
|
+
type: "text",
|
|
74
|
+
text: [
|
|
75
|
+
`❌ Issue #${issueId} is ${issueState}: "${issueTitle}"`,
|
|
76
|
+
``,
|
|
77
|
+
`Cannot start work on a ${issueState} issue.`,
|
|
78
|
+
`Reopen the issue first or pick an open one: project_list_issues().`,
|
|
79
|
+
].join("\n"),
|
|
80
|
+
}],
|
|
81
|
+
isError: true,
|
|
82
|
+
details: { issueId, state: issueState, title: issueTitle },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Warn: assigned to someone else
|
|
87
|
+
if (issueAssignee && issueAssignee !== "factory") {
|
|
88
|
+
warnings.push(`⚠️ Issue #${issueId} is assigned to "${issueAssignee}". Verify you should be working on this.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Build the output prefix with issue context so agent can verify relevance
|
|
92
|
+
const issueInfo = [
|
|
93
|
+
`📋 Issue #${issueId}: "${issueTitle}"`,
|
|
94
|
+
` State: ${issueState}${issueLabels ? ` | Labels: ${issueLabels}` : ""}${issueAssignee ? ` | Assignee: ${issueAssignee}` : ""}`,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
if (warnings.length > 0) {
|
|
98
|
+
issueInfo.push("");
|
|
99
|
+
for (const w of warnings) issueInfo.push(w);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Now proceed with branch handling ──
|
|
103
|
+
|
|
104
|
+
// ═══ Case 1: Already on a feature branch ═══
|
|
105
|
+
if (isFeatureBranch) {
|
|
106
|
+
const remote = remoteBranchExists(ctx.cwd);
|
|
107
|
+
if (!remote.exists) {
|
|
108
|
+
const pr = await checkBranchPRState(ctx.cwd);
|
|
109
|
+
const prNote = pr
|
|
110
|
+
? `\n\nPR ${pr.url} was ${pr.state === "merged" ? "already merged" : "closed"}.`
|
|
111
|
+
: "";
|
|
112
|
+
return {
|
|
113
|
+
content: [{
|
|
114
|
+
type: "text",
|
|
115
|
+
text: [
|
|
116
|
+
...issueInfo,
|
|
117
|
+
"",
|
|
118
|
+
`⚠️ Branch "${currentBr}" has no remote — the PR was likely merged.`,
|
|
119
|
+
`${prNote}`,
|
|
120
|
+
``,
|
|
121
|
+
`Continuing work on this branch risks creating orphaned commits.`,
|
|
122
|
+
``,
|
|
123
|
+
`Recommended: start fresh work on a new branch:`,
|
|
124
|
+
` git checkout main`,
|
|
125
|
+
` contrib_start_work(issue_id=${params.issue_id})`,
|
|
126
|
+
``,
|
|
127
|
+
`To continue on this branch anyway, confirm with the same call again.`,
|
|
128
|
+
].join("\n"),
|
|
129
|
+
}],
|
|
130
|
+
isError: true,
|
|
131
|
+
details: { branch: currentBr, issueId, staleBranch: true },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
(globalThis as any).__contrib_issueId = issueId;
|
|
136
|
+
(globalThis as any).__contrib_branchType = branchType;
|
|
137
|
+
const alreadyLinked = getLinkedIssueId(ctx.cwd) === issueId && currentBr.includes(`issue-${issueId}`);
|
|
138
|
+
const msg = alreadyLinked
|
|
139
|
+
? [...issueInfo, "", `✅ Already on ${currentBr} (linked to #${issueId}). Resuming work.`].join("\n")
|
|
140
|
+
: [...issueInfo, "", `✅ Linked issue #${issueId} to existing branch ${currentBr}.`].join("\n");
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: msg }],
|
|
143
|
+
details: { branch: currentBr, issueId, type: branchType, title: issueTitle },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ═══ Case 2: Not on a feature branch → create one ═══
|
|
148
|
+
if (!isClean(ctx.cwd)) {
|
|
149
|
+
return { content: [{ type: "text", text: "⚠️ Working tree is not clean. Commit or stash changes before starting new work." }], isError: true, details: { clean: false } };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
exec("git pull --ff-only gitea dev 2>/dev/null || git pull --ff-only origin dev 2>/dev/null || true", ctx.cwd);
|
|
153
|
+
|
|
154
|
+
const branchName = `${branchType}/issue-${issueId}`;
|
|
155
|
+
const r2 = exec(`git checkout -b ${shellEscape(branchName)}`, ctx.cwd);
|
|
156
|
+
if (!r2.ok) {
|
|
157
|
+
return { content: [{ type: "text", text: `Failed to create branch: ${r2.stderr}` }], isError: true, details: {} };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
(globalThis as any).__contrib_issueId = issueId;
|
|
161
|
+
(globalThis as any).__contrib_branchType = branchType;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
content: [{
|
|
165
|
+
type: "text",
|
|
166
|
+
text: [
|
|
167
|
+
...issueInfo,
|
|
168
|
+
"",
|
|
169
|
+
`✅ Work started on ${branchName}`,
|
|
170
|
+
` Issue: #${issueId} — ${issueTitle}`,
|
|
171
|
+
` Type: ${branchType}`,
|
|
172
|
+
``,
|
|
173
|
+
`Next: make changes, then contrib_propose(message).`,
|
|
174
|
+
].join("\n"),
|
|
175
|
+
}],
|
|
176
|
+
details: { branch: branchName, issueId, type: branchType, title: issueTitle },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Validation disabled — original flow ──
|
|
181
|
+
|
|
182
|
+
if (isFeatureBranch) {
|
|
183
|
+
const remote = remoteBranchExists(ctx.cwd);
|
|
184
|
+
if (!remote.exists) {
|
|
185
|
+
const pr = await checkBranchPRState(ctx.cwd);
|
|
186
|
+
const prNote = pr
|
|
187
|
+
? `\n\nPR ${pr.url} was ${pr.state === "merged" ? "already merged" : "closed"}.`
|
|
188
|
+
: "";
|
|
189
|
+
return {
|
|
190
|
+
content: [{
|
|
191
|
+
type: "text",
|
|
192
|
+
text: [
|
|
193
|
+
`⚠️ Branch "${currentBr}" has no remote — the PR was likely merged.`,
|
|
194
|
+
`${prNote}`,
|
|
195
|
+
``,
|
|
196
|
+
`Continuing work on this branch risks creating orphaned commits.`,
|
|
197
|
+
``,
|
|
198
|
+
`Recommended: start fresh work on a new branch:`,
|
|
199
|
+
` git checkout main`,
|
|
200
|
+
` contrib_start_work(issue_id=${params.issue_id})`,
|
|
201
|
+
``,
|
|
202
|
+
`To continue on this branch anyway, confirm with the same call again.`,
|
|
203
|
+
].join("\n"),
|
|
204
|
+
}],
|
|
205
|
+
isError: true,
|
|
206
|
+
details: { branch: currentBr, issueId, staleBranch: true },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
(globalThis as any).__contrib_issueId = issueId;
|
|
211
|
+
(globalThis as any).__contrib_branchType = branchType;
|
|
212
|
+
const alreadyLinked = getLinkedIssueId(ctx.cwd) === issueId && currentBr.includes(`issue-${issueId}`);
|
|
213
|
+
const msg = alreadyLinked
|
|
214
|
+
? `✅ Already on ${currentBr} (linked to #${issueId}). Resuming work.`
|
|
215
|
+
: `✅ Linked issue #${issueId} to existing branch ${currentBr}.`;
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: "text", text: msg }],
|
|
218
|
+
details: { branch: currentBr, issueId, type: branchType },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!isClean(ctx.cwd)) {
|
|
223
|
+
return { content: [{ type: "text", text: "⚠️ Working tree is not clean. Commit or stash changes before starting new work." }], isError: true, details: { clean: false } };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
exec("git pull --ff-only gitea dev 2>/dev/null || git pull --ff-only origin dev 2>/dev/null || true", ctx.cwd);
|
|
227
|
+
|
|
228
|
+
const branchName = `${branchType}/issue-${issueId}`;
|
|
229
|
+
const r2 = exec(`git checkout -b ${shellEscape(branchName)}`, ctx.cwd);
|
|
230
|
+
if (!r2.ok) {
|
|
231
|
+
return { content: [{ type: "text", text: `Failed to create branch: ${r2.stderr}` }], isError: true, details: {} };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
(globalThis as any).__contrib_issueId = issueId;
|
|
235
|
+
(globalThis as any).__contrib_branchType = branchType;
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
content: [{ type: "text", text: [`✅ Work started on ${branchName}`, ` Issue: #${issueId}`, ` Type: ${branchType}`, ``, `Next: make changes, then contrib_propose(message).`].join("\n") }],
|
|
239
|
+
details: { branch: branchName, issueId, type: branchType },
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { exec, currentBranch, isClean, hasUnpushed, getLinkedIssueId, remoteBranchExists, checkBranchPRState } from "../helpers";
|
|
4
|
+
|
|
5
|
+
export const statusTool = {
|
|
6
|
+
name: "contrib_status" as const,
|
|
7
|
+
label: "Contribution Status",
|
|
8
|
+
description: "Show current branch, commit status, uncommitted changes, and PR status.",
|
|
9
|
+
parameters: Type.Object({}),
|
|
10
|
+
async execute(_toolCallId: string, _params: any, _signal: any, _onUpdate: any, ctx: ExtensionContext) {
|
|
11
|
+
const branch = currentBranch(ctx.cwd);
|
|
12
|
+
const clean = isClean(ctx.cwd);
|
|
13
|
+
const unpushed = hasUnpushed(ctx.cwd);
|
|
14
|
+
const issueId = getLinkedIssueId(ctx.cwd);
|
|
15
|
+
const lastHash = (globalThis as any).__contrib_lastHash || null;
|
|
16
|
+
|
|
17
|
+
const status = exec("git status --short", ctx.cwd);
|
|
18
|
+
const changes = status.ok && status.stdout ? status.stdout.split("\n").length : 0;
|
|
19
|
+
|
|
20
|
+
const lines = [
|
|
21
|
+
`📋 Contribution Status`,
|
|
22
|
+
` Branch: ${branch || "(detached)"}`,
|
|
23
|
+
` Issue: ${issueId ? `#${issueId}` : "(none)"}`,
|
|
24
|
+
` Clean: ${clean ? "✅" : `❌ (${changes} changed files)`}`,
|
|
25
|
+
` Unpushed: ${unpushed ? "⚠️ yes" : "✅ no"}`,
|
|
26
|
+
` Last commit: ${lastHash || "(none)"}`,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// ── Remote branch health ──
|
|
30
|
+
lines.push("");
|
|
31
|
+
const remote = remoteBranchExists(ctx.cwd);
|
|
32
|
+
if (branch && !remote.exists) {
|
|
33
|
+
lines.push(` 🌐 Remote branch: ❌ deleted (PR likely merged)`);
|
|
34
|
+
lines.push(` ⚠️ Do NOT continue work on this branch.`);
|
|
35
|
+
lines.push(` → Run contrib_start_work(issue_id) to start fresh work.`);
|
|
36
|
+
} else if (remote.exists) {
|
|
37
|
+
lines.push(` 🌐 Remote branch: ✅ on ${remote.remoteName}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── PR state ──
|
|
41
|
+
if (branch) {
|
|
42
|
+
const pr = await checkBranchPRState(ctx.cwd);
|
|
43
|
+
if (pr) {
|
|
44
|
+
const prIcon = pr.state === "merged" ? "🔀" : pr.state === "open" ? "🟢" : "🔴";
|
|
45
|
+
lines.push(` 📬 PR: ${prIcon} ${pr.state.toUpperCase()}`);
|
|
46
|
+
lines.push(` ${pr.url}`);
|
|
47
|
+
if (pr.state === "merged") {
|
|
48
|
+
lines.push(` ⚠️ This PR was already merged. Start fresh work.`);
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
lines.push(` 📬 PR: not found`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!clean) {
|
|
56
|
+
lines.push("", "Changed files:");
|
|
57
|
+
for (const line of (status.stdout || "").split("\n").filter(Boolean).slice(0, 15)) {
|
|
58
|
+
lines.push(` ${line}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
64
|
+
details: { branch, clean, changes, unpushed, issueId },
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { exec, currentBranch, hasUnpushed, createPR, shellEscape, getLinkedIssueId, remoteBranchExists, checkBranchPRState } from "../helpers";
|
|
4
|
+
|
|
5
|
+
export const submitTool = {
|
|
6
|
+
name: "contrib_submit" as const,
|
|
7
|
+
label: "Submit PR",
|
|
8
|
+
description: "Push the current branch and create a pull request.",
|
|
9
|
+
parameters: Type.Object({
|
|
10
|
+
title: Type.String({ description: "PR title" }),
|
|
11
|
+
body: Type.Optional(Type.String({ description: "PR description" })),
|
|
12
|
+
base: Type.Optional(Type.String({ description: "Target branch (default: dev)" })),
|
|
13
|
+
remote: Type.Optional(Type.String({ description: "Git remote name (auto-detects)" })),
|
|
14
|
+
}),
|
|
15
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, ctx: ExtensionContext) {
|
|
16
|
+
const branch = currentBranch(ctx.cwd);
|
|
17
|
+
const base = params.base || "dev";
|
|
18
|
+
|
|
19
|
+
const issueId = getLinkedIssueId(ctx.cwd);
|
|
20
|
+
if (!issueId) {
|
|
21
|
+
return {
|
|
22
|
+
content: [{
|
|
23
|
+
type: "text",
|
|
24
|
+
text: [
|
|
25
|
+
`❌ No Gitea issue linked.`,
|
|
26
|
+
``,
|
|
27
|
+
`Before submitting a PR, link your work to an issue with:`,
|
|
28
|
+
` contrib_start_work(issue_id)`,
|
|
29
|
+
``,
|
|
30
|
+
`This ensures the PR closes the correct issue.`,
|
|
31
|
+
].join("\n"),
|
|
32
|
+
}],
|
|
33
|
+
isError: true,
|
|
34
|
+
details: {},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!hasUnpushed(ctx.cwd) && !(globalThis as any).__contrib_lastHash) {
|
|
39
|
+
return { content: [{ type: "text", text: "No commits to push. Run contrib_propose() first." }], isError: true, details: {} };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Pre-flight: Check if remote branch was deleted (PR likely merged) ──
|
|
43
|
+
const remote = remoteBranchExists(ctx.cwd);
|
|
44
|
+
if (!remote.exists) {
|
|
45
|
+
const pr = await checkBranchPRState(ctx.cwd);
|
|
46
|
+
if (pr?.state === "merged") {
|
|
47
|
+
return {
|
|
48
|
+
content: [{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: [
|
|
51
|
+
`⛔ Cannot submit: PR for "${branch}" was already merged.`,
|
|
52
|
+
``,
|
|
53
|
+
` Merged PR: ${pr.url}`,
|
|
54
|
+
``,
|
|
55
|
+
`The remote branch was deleted after merge. Continuing on this`,
|
|
56
|
+
`local branch creates orphaned work that is hard to track.`,
|
|
57
|
+
``,
|
|
58
|
+
`Instead:`,
|
|
59
|
+
` 1. Switch to the base branch: git checkout main`,
|
|
60
|
+
` 2. Start fresh work: contrib_start_work(issue_id)`,
|
|
61
|
+
].join("\n"),
|
|
62
|
+
}],
|
|
63
|
+
isError: true,
|
|
64
|
+
details: { branch, prState: pr.state, prUrl: pr.url },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Remote deleted but PR state unknown — warn but allow
|
|
68
|
+
ctx.ui.notify(
|
|
69
|
+
"Remote branch deleted",
|
|
70
|
+
`Remote branch "${branch}" no longer exists. It may have been merged. Proceeding anyway...`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check PR body for content that breaks CI shell scripts
|
|
75
|
+
const prBodyRaw = params.body || "";
|
|
76
|
+
if (prBodyRaw.includes("```")) {
|
|
77
|
+
return {
|
|
78
|
+
content: [{
|
|
79
|
+
type: "text",
|
|
80
|
+
text: [
|
|
81
|
+
"⚠️ PR body contains triple-backtick code blocks (```).",
|
|
82
|
+
"",
|
|
83
|
+
"These break the CI agent signature check step because the PR body",
|
|
84
|
+
"gets injected into a shell script. Use plain text instead:",
|
|
85
|
+
"",
|
|
86
|
+
"Instead of:",
|
|
87
|
+
" ```bash",
|
|
88
|
+
" docker compose up -d",
|
|
89
|
+
" ```",
|
|
90
|
+
"",
|
|
91
|
+
"Use:",
|
|
92
|
+
" docker compose up -d",
|
|
93
|
+
"",
|
|
94
|
+
"Remove all ``` markers and retry.",
|
|
95
|
+
].join("\n"),
|
|
96
|
+
}],
|
|
97
|
+
isError: true,
|
|
98
|
+
details: {},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let remoteName = params.remote || "";
|
|
103
|
+
if (!remoteName) {
|
|
104
|
+
const remotes = exec("git remote", ctx.cwd);
|
|
105
|
+
const remoteList = remotes.ok ? remotes.stdout.split("\n").filter(Boolean) : [];
|
|
106
|
+
if (remoteList.includes("gitea")) remoteName = "gitea";
|
|
107
|
+
else if (remoteList.includes("origin")) remoteName = "origin";
|
|
108
|
+
else remoteName = remoteList[0] || "origin";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const push = exec(`git push -u ${shellEscape(remoteName)} ${shellEscape(branch)}`, ctx.cwd);
|
|
112
|
+
if (!push.ok) return { content: [{ type: "text", text: `Push failed: ${push.stderr}` }], isError: true, details: {} };
|
|
113
|
+
|
|
114
|
+
let prBody = params.body || "";
|
|
115
|
+
if (issueId) prBody += `\n\nCloses #${issueId}`;
|
|
116
|
+
|
|
117
|
+
const pr = await createPR(branch, base, params.title, prBody, ctx, remoteName);
|
|
118
|
+
if (!pr.ok) return { content: [{ type: "text", text: `Push succeeded but PR creation failed: ${pr.error}` }], isError: true, details: { branch } };
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text", text: [`🎉 PR created successfully!`, ` ${pr.url}`, ` Branch: ${branch} → ${base}`].join("\n") }],
|
|
122
|
+
details: { url: pr.url, branch, base },
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface BestPracticesConfig {
|
|
2
|
+
shortFrequentCommits: boolean;
|
|
3
|
+
maxLinesPerCommit: number;
|
|
4
|
+
requireAtomic: boolean;
|
|
5
|
+
maxUnrelatedDirs: number;
|
|
6
|
+
guidanceText: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ContribConfig {
|
|
10
|
+
branches: {
|
|
11
|
+
featPattern: string;
|
|
12
|
+
fixPattern: string;
|
|
13
|
+
chorePattern: string;
|
|
14
|
+
};
|
|
15
|
+
commits: {
|
|
16
|
+
convention: "conventional" | "simple";
|
|
17
|
+
maxSubjectLength: number;
|
|
18
|
+
scopes: string[];
|
|
19
|
+
bestPractices: BestPracticesConfig;
|
|
20
|
+
};
|
|
21
|
+
quality: {
|
|
22
|
+
lint: boolean;
|
|
23
|
+
typeCheck: boolean;
|
|
24
|
+
doctorAudit: boolean;
|
|
25
|
+
maxFilesChanged: number;
|
|
26
|
+
maxLinesAdded: number;
|
|
27
|
+
};
|
|
28
|
+
/** Validate that the linked Gitea issue actually exists before starting work (default: true) */
|
|
29
|
+
requireIssueValidation: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const BEST_PRACTICES_DEFAULTS: BestPracticesConfig = {
|
|
33
|
+
shortFrequentCommits: true,
|
|
34
|
+
maxLinesPerCommit: 150,
|
|
35
|
+
requireAtomic: true,
|
|
36
|
+
maxUnrelatedDirs: 3,
|
|
37
|
+
guidanceText: [
|
|
38
|
+
"Commit after every logical unit of work.",
|
|
39
|
+
"Aim for < 150 lines per commit.",
|
|
40
|
+
"Each commit should do one thing — keep changes atomic.",
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_CONFIG: ContribConfig = {
|
|
45
|
+
branches: {
|
|
46
|
+
featPattern: "feat/",
|
|
47
|
+
fixPattern: "fix/",
|
|
48
|
+
chorePattern: "chore/",
|
|
49
|
+
},
|
|
50
|
+
commits: {
|
|
51
|
+
convention: "conventional",
|
|
52
|
+
maxSubjectLength: 72,
|
|
53
|
+
scopes: [],
|
|
54
|
+
bestPractices: { ...BEST_PRACTICES_DEFAULTS },
|
|
55
|
+
},
|
|
56
|
+
quality: {
|
|
57
|
+
lint: true,
|
|
58
|
+
typeCheck: true,
|
|
59
|
+
doctorAudit: true,
|
|
60
|
+
maxFilesChanged: 20,
|
|
61
|
+
maxLinesAdded: 500,
|
|
62
|
+
},
|
|
63
|
+
requireIssueValidation: true,
|
|
64
|
+
};
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { ContribConfig } from "./types";
|
|
3
|
+
import { exec, scanForConflictMarkers } from "./helpers";
|
|
4
|
+
|
|
5
|
+
export function validateConventionalCommit(message: string, config: ContribConfig): { ok: true } | { ok: false; error: string } {
|
|
6
|
+
const firstLine = message.split("\n")[0].trim();
|
|
7
|
+
const convPattern = /^(feat|fix|chore|docs|style|refactor|test|perf|ci|build|revert)(\([^)]+\))?:\s.+$/;
|
|
8
|
+
if (!convPattern.test(firstLine)) {
|
|
9
|
+
return { ok: false, error: `Commit message must follow conventional commits format: type(scope): subject\nExamples: feat(api): add endpoint, fix: resolve null pointer, chore: update deps` };
|
|
10
|
+
}
|
|
11
|
+
if (firstLine.length > config.commits.maxSubjectLength) {
|
|
12
|
+
return { ok: false, error: `Subject line too long (${firstLine.length} > ${config.commits.maxSubjectLength} chars).` };
|
|
13
|
+
}
|
|
14
|
+
return { ok: true };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validateBranchName(branch: string): { ok: true } | { ok: false; error: string } {
|
|
18
|
+
if (/^(feat|fix|chore)\//.test(branch)) return { ok: true };
|
|
19
|
+
return { ok: false, error: `Branch name "${branch}" does not follow convention: feat/*, fix/*, or chore/*` };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function runQualityGate(cwd: string, config: ContribConfig): { ok: true } | { ok: false; errors: string[] } {
|
|
23
|
+
const errors: string[] = [];
|
|
24
|
+
|
|
25
|
+
// ── Conflict marker check (MUST come first — safety-critical) ──
|
|
26
|
+
const conflictFiles = scanForConflictMarkers(cwd);
|
|
27
|
+
if (conflictFiles.length > 0) {
|
|
28
|
+
errors.push(
|
|
29
|
+
`Unresolved merge conflict markers found in staged files: ${conflictFiles.join(", ")}.\n Remove all <<<<<<<, =======, >>>>>>> markers before committing.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const diff = exec("git diff --cached --name-only", cwd);
|
|
34
|
+
if (diff.ok) {
|
|
35
|
+
const files = diff.stdout.split("\n").filter(Boolean);
|
|
36
|
+
if (files.length > config.quality.maxFilesChanged) {
|
|
37
|
+
errors.push(`Too many files changed (${files.length} > ${config.quality.maxFilesChanged}).`);
|
|
38
|
+
}
|
|
39
|
+
const loc = exec("git diff --cached --numstat | awk '{s+=$1} END {print s}'", cwd);
|
|
40
|
+
if (loc.ok && parseInt(loc.stdout) > config.quality.maxLinesAdded) {
|
|
41
|
+
errors.push(`Too many lines added (${loc.stdout} > ${config.quality.maxLinesAdded}).`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (config.quality.typeCheck) {
|
|
45
|
+
const tsc = exec("npx tsc --noEmit 2>&1 || true", path.join(cwd, "factory"));
|
|
46
|
+
if (tsc.stdout.includes("error TS")) errors.push("TypeScript errors found. Run: npx tsc --noEmit");
|
|
47
|
+
}
|
|
48
|
+
if (config.quality.lint) {
|
|
49
|
+
const lint = exec("npm run lint 2>&1 || true", path.join(cwd, "factory"));
|
|
50
|
+
if (lint.stdout.includes("error") && !lint.stdout.includes("0 errors")) errors.push("Lint errors found.");
|
|
51
|
+
}
|
|
52
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
53
|
+
}
|