nitpiq 0.1.0 → 0.3.0

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/src/git/repo.ts DELETED
@@ -1,237 +0,0 @@
1
- import { readFileSync, statSync } from "node:fs";
2
- import path from "node:path";
3
-
4
- export type ChangeKind = "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked";
5
-
6
- export interface FileChange {
7
- path: string;
8
- oldPath: string;
9
- kind: ChangeKind;
10
- staged: boolean;
11
- unstaged: boolean;
12
- }
13
-
14
- export interface Repo {
15
- root: string;
16
- name: string;
17
- hasHead: boolean;
18
- }
19
-
20
- export function kindSymbol(kind: ChangeKind): string {
21
- switch (kind) {
22
- case "modified":
23
- return "M";
24
- case "added":
25
- return "A";
26
- case "deleted":
27
- return "D";
28
- case "renamed":
29
- return "R";
30
- case "copied":
31
- return "C";
32
- case "untracked":
33
- return "?";
34
- }
35
- }
36
-
37
- export function openRepoAt(dir?: string): Repo {
38
- const root = runGit(["rev-parse", "--show-toplevel"], dir || process.cwd()).trim();
39
- const hasHead = runGitOptional(["rev-parse", "HEAD"], root).ok;
40
-
41
- return {
42
- root,
43
- name: path.basename(root),
44
- hasHead,
45
- };
46
- }
47
-
48
- export function openRepo(): Repo {
49
- return openRepoAt();
50
- }
51
-
52
- export function changes(repo: Repo): FileChange[] {
53
- const output = runGit(["status", "--porcelain=v1", "-uall"], repo.root);
54
- return parsePorcelain(output);
55
- }
56
-
57
- export function files(repo: Repo): string[] {
58
- const output = runGit(["ls-files", "--cached", "--others", "--exclude-standard"], repo.root);
59
- const seen = new Set<string>();
60
- const result: string[] = [];
61
-
62
- for (const line of output.split("\n")) {
63
- const candidate = line.trim();
64
- if (!candidate || seen.has(candidate)) {
65
- continue;
66
- }
67
- seen.add(candidate);
68
- result.push(candidate);
69
- }
70
-
71
- return result;
72
- }
73
-
74
- export function diff(repo: Repo, change: FileChange, contextLines = 3): string {
75
- if (change.kind === "untracked") {
76
- return diffUntracked(repo, change.path);
77
- }
78
-
79
- const context = `-U${contextLines}`;
80
-
81
- if (repo.hasHead) {
82
- const result = runGitOptional(["diff", context, "HEAD", "--", change.path], repo.root);
83
- if (result.ok && result.stdout.trim()) {
84
- return result.stdout;
85
- }
86
- }
87
-
88
- const cached = runGitOptional(["diff", context, "--cached", "--", change.path], repo.root);
89
- if (cached.ok && cached.stdout.trim()) {
90
- return cached.stdout;
91
- }
92
-
93
- const working = runGitOptional(["diff", context, "--", change.path], repo.root);
94
- if (working.ok && working.stdout.trim()) {
95
- return working.stdout;
96
- }
97
-
98
- return "(no diff available)";
99
- }
100
-
101
- function diffUntracked(repo: Repo, relativePath: string): string {
102
- const absolutePath = path.join(repo.root, relativePath);
103
- const stat = statSync(absolutePath);
104
- if (stat.isDirectory()) {
105
- return `(directory: ${relativePath})`;
106
- }
107
-
108
- const result = Bun.spawnSync(["git", "diff", "--no-index", "--", "/dev/null", absolutePath], {
109
- cwd: repo.root,
110
- stdout: "pipe",
111
- stderr: "pipe",
112
- });
113
-
114
- const text = `${result.stdout ? Buffer.from(result.stdout).toString() : ""}${result.stderr ? Buffer.from(result.stderr).toString() : ""}`;
115
- return text || "(empty file)";
116
- }
117
-
118
- export function readFile(repo: Repo, relativePath: string): string {
119
- const filePath = path.join(repo.root, relativePath);
120
- const stat = statSync(filePath);
121
- if (stat.isDirectory()) {
122
- throw new Error(`${relativePath} is a directory`);
123
- }
124
- return readFileSync(filePath, "utf8");
125
- }
126
-
127
- export function stage(repo: Repo, relativePath: string): void {
128
- runGit(["add", "--", relativePath], repo.root);
129
- }
130
-
131
- export function unstage(repo: Repo, relativePath: string): void {
132
- const restore = runGitOptional(["restore", "--staged", "--", relativePath], repo.root);
133
- if (restore.ok) {
134
- return;
135
- }
136
-
137
- const reset = runGitOptional(["reset", "HEAD", "--", relativePath], repo.root);
138
- if (reset.ok) {
139
- return;
140
- }
141
-
142
- const remove = runGitOptional(["rm", "--cached", "--", relativePath], repo.root);
143
- if (!remove.ok) {
144
- throw new Error(remove.stderr || `failed to unstage ${relativePath}`);
145
- }
146
- }
147
-
148
- function parsePorcelain(output: string): FileChange[] {
149
- const result: FileChange[] = [];
150
-
151
- for (const line of output.split("\n")) {
152
- if (line.length < 4) {
153
- continue;
154
- }
155
-
156
- const x = line[0] ?? " ";
157
- const y = line[1] ?? " ";
158
- let filePath = line.slice(3);
159
- let oldPath = "";
160
-
161
- if (filePath.endsWith("/")) {
162
- continue;
163
- }
164
-
165
- const renameIndex = filePath.indexOf(" -> ");
166
- if (renameIndex >= 0) {
167
- oldPath = filePath.slice(0, renameIndex);
168
- filePath = filePath.slice(renameIndex + 4);
169
- }
170
-
171
- const change: FileChange = {
172
- path: filePath,
173
- oldPath,
174
- kind: "modified",
175
- staged: false,
176
- unstaged: false,
177
- };
178
-
179
- if (x === "?" && y === "?") {
180
- change.kind = "untracked";
181
- change.unstaged = true;
182
- } else {
183
- if (x !== " " && x !== "?") {
184
- change.staged = true;
185
- change.kind = charToKind(x);
186
- }
187
- if (y !== " " && y !== "?") {
188
- change.unstaged = true;
189
- if (!change.staged) {
190
- change.kind = charToKind(y);
191
- }
192
- }
193
- }
194
-
195
- result.push(change);
196
- }
197
-
198
- return result;
199
- }
200
-
201
- function charToKind(char: string): ChangeKind {
202
- switch (char) {
203
- case "A":
204
- return "added";
205
- case "D":
206
- return "deleted";
207
- case "R":
208
- return "renamed";
209
- case "C":
210
- return "copied";
211
- case "M":
212
- default:
213
- return "modified";
214
- }
215
- }
216
-
217
- function runGit(args: string[], cwd: string): string {
218
- const result = runGitOptional(args, cwd);
219
- if (!result.ok) {
220
- throw new Error(result.stderr || `git ${args.join(" ")} failed`);
221
- }
222
- return result.stdout;
223
- }
224
-
225
- function runGitOptional(args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } {
226
- const proc = Bun.spawnSync(["git", ...args], {
227
- cwd,
228
- stdout: "pipe",
229
- stderr: "pipe",
230
- });
231
-
232
- return {
233
- ok: proc.exitCode === 0,
234
- stdout: proc.stdout ? Buffer.from(proc.stdout).toString() : "",
235
- stderr: proc.stderr ? Buffer.from(proc.stderr).toString().trim() : "",
236
- };
237
- }
package/src/log/log.ts DELETED
@@ -1,27 +0,0 @@
1
- import { mkdirSync, appendFileSync } from "node:fs";
2
- import path from "node:path";
3
-
4
- let logPath: string | null = null;
5
-
6
- export function initLog(repoRoot: string): void {
7
- const dir = path.join(repoRoot, ".git", "nitpiq");
8
- mkdirSync(dir, { recursive: true });
9
- logPath = path.join(dir, "debug.log");
10
- }
11
-
12
- function write(level: string, message: string): void {
13
- if (!logPath) {
14
- return;
15
- }
16
-
17
- const line = `[${new Date().toISOString()}] ${level} ${message}\n`;
18
- appendFileSync(logPath, line);
19
- }
20
-
21
- export function debug(message: string): void {
22
- write("DEBUG", message);
23
- }
24
-
25
- export function error(message: string): void {
26
- write("ERROR", message);
27
- }
@@ -1,37 +0,0 @@
1
- import { mkdirSync, mkdtempSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import path from "node:path";
4
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
- import { describe, expect, test } from "bun:test";
7
-
8
- describe("nitpiq MCP server", () => {
9
- test("keeps the store open after connect", async () => {
10
- const repoRoot = mkdtempSync(path.join(tmpdir(), "nitpiq-mcp-"));
11
- await Bun.write(path.join(repoRoot, "file.ts"), "export const value = 1;\n");
12
-
13
- Bun.spawnSync(["git", "init"], { cwd: repoRoot, stdout: "ignore", stderr: "ignore" });
14
-
15
- const dbDir = path.join(repoRoot, ".git", "nitpiq");
16
- mkdirSync(dbDir, { recursive: true });
17
-
18
- const client = new Client({ name: "nitpiq-test", version: "0.0.0" }, { capabilities: {} });
19
- const transport = new StdioClientTransport({
20
- command: "bun",
21
- args: ["run", path.join(process.cwd(), "src/cli/nitpiq-mcp.ts"), repoRoot],
22
- cwd: process.cwd(),
23
- stderr: "pipe",
24
- });
25
-
26
- await client.connect(transport);
27
-
28
- const result = await client.callTool({
29
- name: "review_list_threads",
30
- arguments: { status: "open" },
31
- });
32
-
33
- expect(result.isError).not.toBeTrue();
34
-
35
- await transport.close();
36
- });
37
- });
package/src/mcp/server.ts DELETED
@@ -1,210 +0,0 @@
1
- import { mkdirSync } from "node:fs";
2
- import path from "node:path";
3
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { z } from "zod";
6
- import { changes, kindSymbol, openRepoAt, readFile, stage, unstage, type Repo } from "../git/repo";
7
- import { debug, error, initLog } from "../log/log";
8
- import { relocateThreads } from "../review/anchor";
9
- import { AuthorModel, ThreadOpen, ThreadResolved } from "../review/types";
10
- import { Store } from "../store/store";
11
-
12
- export function createServer(repo: Repo, store: Store): McpServer {
13
- const server = new McpServer({ name: "nitpiq", version: "0.1.0" });
14
-
15
- const activeSession = () => store.activeSession() ?? store.createSession(repo.root);
16
-
17
- server.registerTool(
18
- "review_list_changes",
19
- {
20
- description: "List uncommitted file changes in the repository.",
21
- },
22
- async () => ({
23
- content: [
24
- {
25
- type: "text",
26
- text: JSON.stringify(
27
- changes(repo).map((change) => ({
28
- path: change.path,
29
- kind: change.kind,
30
- symbol: kindSymbol(change.kind),
31
- })),
32
- null,
33
- 2,
34
- ),
35
- },
36
- ],
37
- }),
38
- );
39
-
40
- server.registerTool(
41
- "review_list_threads",
42
- {
43
- description: "List review threads, optionally filtered by file and status.",
44
- inputSchema: {
45
- file_path: z.string().optional(),
46
- status: z.enum(["open", "resolved", "all"]).optional(),
47
- },
48
- },
49
- async ({ file_path, status = "open" }) => {
50
- const session = activeSession();
51
- let threads = store.listThreads(session.id, file_path ?? "");
52
-
53
- if (file_path) {
54
- const content = readFile(repo, file_path);
55
- threads = relocateThreads(threads, content.split("\n"));
56
- for (const thread of threads) {
57
- store.updateThreadLine(thread.id, thread.currentLine, thread.isOutdated);
58
- }
59
- }
60
-
61
- const filtered = threads.filter((thread) => status === "all" || thread.status === status);
62
- return {
63
- content: [
64
- {
65
- type: "text",
66
- text: JSON.stringify(
67
- filtered.map((thread) => ({
68
- id: thread.id,
69
- file_path: thread.filePath,
70
- line: thread.currentLine,
71
- line_end: thread.lineEnd || undefined,
72
- status: thread.status,
73
- is_outdated: thread.isOutdated || undefined,
74
- comment_count: thread.commentCount,
75
- first_comment: thread.firstComment,
76
- })),
77
- null,
78
- 2,
79
- ),
80
- },
81
- ],
82
- };
83
- },
84
- );
85
-
86
- server.registerTool(
87
- "review_reply_thread",
88
- {
89
- description: "Post a reply comment to an existing review thread.",
90
- inputSchema: {
91
- thread_id: z.string(),
92
- body: z.string(),
93
- },
94
- },
95
- async ({ thread_id, body }) => {
96
- store.addComment({ threadId: thread_id, author: AuthorModel, body });
97
- return textResult(`Reply added to thread ${thread_id}`);
98
- },
99
- );
100
-
101
- server.registerTool(
102
- "review_resolve_thread",
103
- {
104
- description: "Mark a review thread as resolved.",
105
- inputSchema: { thread_id: z.string() },
106
- },
107
- async ({ thread_id }) => {
108
- store.updateThreadStatus(thread_id, ThreadResolved);
109
- return textResult(`Thread ${thread_id} resolved`);
110
- },
111
- );
112
-
113
- server.registerTool(
114
- "review_reopen_thread",
115
- {
116
- description: "Reopen a resolved review thread.",
117
- inputSchema: { thread_id: z.string() },
118
- },
119
- async ({ thread_id }) => {
120
- store.updateThreadStatus(thread_id, ThreadOpen);
121
- return textResult(`Thread ${thread_id} reopened`);
122
- },
123
- );
124
-
125
- server.registerTool(
126
- "review_apply_edit",
127
- {
128
- description: "Write new content to a file in the repository.",
129
- inputSchema: {
130
- file_path: z.string(),
131
- content: z.string(),
132
- },
133
- },
134
- async ({ file_path, content }) => {
135
- const absolutePath = path.join(repo.root, file_path);
136
- mkdirSync(path.dirname(absolutePath), { recursive: true });
137
- await Bun.write(absolutePath, content);
138
- return textResult(`File ${file_path} updated (${content.length} bytes)`);
139
- },
140
- );
141
-
142
- server.registerTool(
143
- "review_stage_file",
144
- {
145
- description: "Stage a file with git add.",
146
- inputSchema: { file_path: z.string() },
147
- },
148
- async ({ file_path }) => {
149
- stage(repo, file_path);
150
- return textResult(`File ${file_path} staged`);
151
- },
152
- );
153
-
154
- server.registerTool(
155
- "review_unstage_file",
156
- {
157
- description: "Unstage a file.",
158
- inputSchema: { file_path: z.string() },
159
- },
160
- async ({ file_path }) => {
161
- unstage(repo, file_path);
162
- return textResult(`File ${file_path} unstaged`);
163
- },
164
- );
165
-
166
- return server;
167
- }
168
-
169
- function textResult(text: string) {
170
- return { content: [{ type: "text" as const, text }] };
171
- }
172
-
173
- export async function serveStdio(repoPath?: string): Promise<void> {
174
- const repo = openRepoAt(repoPath);
175
- const store = Store.open(repo.root);
176
- initLog(repo.root);
177
- debug("nitpiq-mcp server starting");
178
-
179
- try {
180
- const server = createServer(repo, store);
181
- const transport = new StdioServerTransport();
182
- let cleanedUp = false;
183
- const cleanup = async () => {
184
- if (cleanedUp) {
185
- return;
186
- }
187
- cleanedUp = true;
188
- store.close();
189
- };
190
- const waitForExit = new Promise<void>((resolve) => {
191
- transport.onclose = () => {
192
- void cleanup().finally(resolve);
193
- };
194
- process.once("SIGINT", () => {
195
- void cleanup().finally(resolve);
196
- });
197
- process.once("SIGTERM", () => {
198
- void cleanup().finally(resolve);
199
- });
200
- process.once("beforeExit", () => {
201
- void cleanup().finally(resolve);
202
- });
203
- });
204
- await server.connect(transport);
205
- await waitForExit;
206
- } catch (cause) {
207
- error(String(cause));
208
- throw cause;
209
- }
210
- }
@@ -1,118 +0,0 @@
1
- import type { Thread } from "./types";
2
-
3
- const RELOCATE_MAX_DELTA = 50;
4
-
5
- export function relocateThreads(threads: Thread[], fileLines: string[]): Thread[] {
6
- return threads.map((thread) => relocateThread(thread, fileLines));
7
- }
8
-
9
- function relocateThread(thread: Thread, lines: string[]): Thread {
10
- if (thread.currentLine <= 0 || lines.length === 0) {
11
- return thread;
12
- }
13
-
14
- const next = { ...thread };
15
- const anchorLines = next.anchorContent.split("\n");
16
- const originalIndex = next.currentLine - 1;
17
- const rangeLength = next.lineEnd > next.currentLine ? next.lineEnd - next.currentLine : 0;
18
-
19
- if (anchorMatchesAt(lines, originalIndex, anchorLines)) {
20
- next.isOutdated = false;
21
- return next;
22
- }
23
-
24
- for (let delta = 1; delta <= RELOCATE_MAX_DELTA; delta += 1) {
25
- const up = originalIndex - delta;
26
- if (up >= 0 && anchorMatchesAt(lines, up, anchorLines)) {
27
- next.currentLine = up + 1;
28
- next.lineEnd = rangeLength > 0 ? next.currentLine + rangeLength : next.lineEnd;
29
- next.isOutdated = false;
30
- return next;
31
- }
32
-
33
- const down = originalIndex + delta;
34
- if (down >= 0 && anchorMatchesAt(lines, down, anchorLines)) {
35
- next.currentLine = down + 1;
36
- next.lineEnd = rangeLength > 0 ? next.currentLine + rangeLength : next.lineEnd;
37
- next.isOutdated = false;
38
- return next;
39
- }
40
- }
41
-
42
- if (next.contextBefore || next.contextAfter) {
43
- for (let index = 0; index < lines.length; index += 1) {
44
- if (matchesContext(lines, index, next.contextBefore, next.contextAfter)) {
45
- next.currentLine = index + 1;
46
- next.lineEnd = rangeLength > 0 ? next.currentLine + rangeLength : next.lineEnd;
47
- next.isOutdated = true;
48
- return next;
49
- }
50
- }
51
- }
52
-
53
- next.isOutdated = true;
54
- return next;
55
- }
56
-
57
- function anchorMatchesAt(lines: string[], index: number, anchorLines: string[]): boolean {
58
- if (index < 0 || index + anchorLines.length > lines.length) {
59
- return false;
60
- }
61
-
62
- for (let offset = 0; offset < anchorLines.length; offset += 1) {
63
- if (lines[index + offset] !== anchorLines[offset]) {
64
- return false;
65
- }
66
- }
67
-
68
- return true;
69
- }
70
-
71
- function matchesContext(lines: string[], index: number, before: string, after: string): boolean {
72
- if (before) {
73
- const beforeLines = before.split("\n");
74
- for (let offset = 0; offset < beforeLines.length; offset += 1) {
75
- const pos = index - beforeLines.length + offset;
76
- if (pos < 0 || pos >= lines.length || lines[pos] !== beforeLines[offset]) {
77
- return false;
78
- }
79
- }
80
- }
81
-
82
- if (after) {
83
- const afterLines = after.split("\n");
84
- for (let offset = 0; offset < afterLines.length; offset += 1) {
85
- const pos = index + 1 + offset;
86
- if (pos >= lines.length || lines[pos] !== afterLines[offset]) {
87
- return false;
88
- }
89
- }
90
- }
91
-
92
- return Boolean(before || after);
93
- }
94
-
95
- export function extractContext(fileContent: string, lineNumber: number) {
96
- const lines = fileContent.split("\n");
97
- const index = lineNumber - 1;
98
-
99
- if (index < 0 || index >= lines.length) {
100
- return { anchor: "", before: "", after: "" };
101
- }
102
-
103
- const beforeStart = Math.max(0, index - 3);
104
- const afterEnd = Math.min(lines.length, index + 4);
105
-
106
- return {
107
- anchor: lines[index] ?? "",
108
- before: lines.slice(beforeStart, index).join("\n"),
109
- after: lines.slice(index + 1, afterEnd).join("\n"),
110
- };
111
- }
112
-
113
- export function extractRangeAnchor(fileContent: string, startLine: number, endLine: number): string {
114
- const lines = fileContent.split("\n");
115
- const start = Math.max(0, startLine - 1);
116
- const end = Math.min(lines.length, endLine);
117
- return lines.slice(start, end).join("\n");
118
- }
@@ -1,64 +0,0 @@
1
- export type ThreadStatus = "open" | "resolved";
2
-
3
- export const ThreadOpen: ThreadStatus = "open";
4
- export const ThreadResolved: ThreadStatus = "resolved";
5
-
6
- export type Author = "human" | "model";
7
-
8
- export const AuthorHuman: Author = "human";
9
- export const AuthorModel: Author = "model";
10
-
11
- export interface ReviewSession {
12
- id: string;
13
- repoRoot: string;
14
- active: boolean;
15
- createdAt: Date;
16
- updatedAt: Date;
17
- }
18
-
19
- export interface Thread {
20
- id: string;
21
- sessionId: string;
22
- filePath: string;
23
- side: string;
24
- originalLine: number;
25
- lineEnd: number;
26
- currentLine: number;
27
- anchorContent: string;
28
- contextBefore: string;
29
- contextAfter: string;
30
- isOutdated: boolean;
31
- status: ThreadStatus;
32
- createdAt: Date;
33
- updatedAt: Date;
34
- commentCount: number;
35
- firstComment: string;
36
- }
37
-
38
- export interface Comment {
39
- id: string;
40
- threadId: string;
41
- author: Author;
42
- body: string;
43
- createdAt: Date;
44
- }
45
-
46
- export interface NewThread {
47
- sessionId: string;
48
- filePath: string;
49
- side: string;
50
- originalLine: number;
51
- lineEnd: number;
52
- currentLine: number;
53
- anchorContent: string;
54
- contextBefore: string;
55
- contextAfter: string;
56
- isOutdated?: boolean;
57
- status?: ThreadStatus;
58
- }
59
-
60
- export interface NewComment {
61
- threadId: string;
62
- author: Author;
63
- body: string;
64
- }