@nt-ai-lab/opencode-skillz 0.3.14 → 0.4.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 +31 -0
- package/agents/default.md +4 -0
- package/agents/tdd.md +1 -0
- package/commands/plan.md +135 -45
- package/commands/resolve-pr-feedback.md +138 -0
- package/commands/review-pr.md +13 -20
- package/dist/commands/dont-stop/hooks.js +69 -35
- package/dist/git-workflow-gates.d.ts +21 -0
- package/dist/git-workflow-gates.js +103 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -4
- package/dist/plugin-registry/agents.js +36 -25
- package/dist/plugin-registry/commands.js +13 -10
- package/dist/plugin-registry/index.js +32 -1
- package/dist/plugin-registry/markdown.js +26 -11
- package/dist/tools/create-pr-tool.d.ts +4 -0
- package/dist/tools/create-pr-tool.js +22 -0
- package/dist/tools/infra/lint/guidance.d.ts +8 -0
- package/dist/tools/infra/lint/guidance.js +98 -0
- package/dist/tools/infra/lint/review.d.ts +19 -0
- package/dist/tools/infra/lint/review.js +53 -0
- package/dist/tools/infra/pull-request/create-draft-pull-request.d.ts +11 -0
- package/dist/tools/infra/pull-request/create-draft-pull-request.js +115 -0
- package/dist/tools/infra/pull-request/feedback.d.ts +14 -0
- package/dist/tools/infra/pull-request/feedback.js +280 -0
- package/dist/tools/infra/source-control/changed-files.d.ts +20 -0
- package/dist/tools/infra/source-control/changed-files.js +78 -0
- package/dist/tools/infra/vitest-coverage/command.d.ts +16 -0
- package/dist/tools/infra/vitest-coverage/command.js +85 -0
- package/dist/tools/infra/vitest-coverage/review.d.ts +50 -0
- package/dist/tools/infra/vitest-coverage/review.js +298 -0
- package/dist/tools/infra/vitest-coverage/test-support.d.ts +15 -0
- package/dist/tools/infra/vitest-coverage/test-support.js +156 -0
- package/dist/tools/lint.d.ts +3 -13
- package/dist/tools/lint.js +56 -16
- package/dist/tools/pull-request-feedback-tool.d.ts +5 -0
- package/dist/tools/pull-request-feedback-tool.js +23 -0
- package/dist/tools/vitest-coverage-tool.d.ts +4 -0
- package/dist/tools/vitest-coverage-tool.js +31 -0
- package/dist/types.d.ts +8 -0
- package/package.json +10 -3
- package/scripts/check-tools-folder-boundary.mjs +78 -0
- package/scripts/install-git-hooks.mjs +54 -0
- package/scripts/lint-ts.mjs +32 -7
- package/scripts/living-architecture-eslint.config.mjs +4 -2
- package/scripts/no-generic-names-eslint-rule.mjs +2 -24
- package/scripts/no-generic-names-eslint-rule.mjs.d.ts +28 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export const CREATE_PULL_REQUEST_TOOL_NAME = "nt_skillz_create_pr";
|
|
2
|
+
const semanticCommitTitlePattern = /^(feat|fix|refactor|perf|docs|test|build|ci|release|chore)\([^)]+\): .+/;
|
|
3
|
+
class CreatePullRequestError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function normalizeRequiredText(value, fieldName) {
|
|
9
|
+
const trimmedValue = value.trim();
|
|
10
|
+
if (trimmedValue) {
|
|
11
|
+
return trimmedValue;
|
|
12
|
+
}
|
|
13
|
+
throw new CreatePullRequestError(`Expected ${fieldName} to be provided. Got empty text.`);
|
|
14
|
+
}
|
|
15
|
+
function runCommand(repositoryRoot, commandRunner, executable, commandArguments, description) {
|
|
16
|
+
const commandResult = commandRunner.run(executable, commandArguments, repositoryRoot);
|
|
17
|
+
if (commandResult.errorMessage) {
|
|
18
|
+
throw new CreatePullRequestError(`Expected ${description} to run. Got ${commandResult.errorMessage}.`);
|
|
19
|
+
}
|
|
20
|
+
if (commandResult.status !== 0) {
|
|
21
|
+
throw new CreatePullRequestError(`Expected ${description} to succeed. Got ${readCommandFailureMessage(commandResult)}.`);
|
|
22
|
+
}
|
|
23
|
+
return commandResult.stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
function readCommandFailureMessage(commandResult) {
|
|
26
|
+
const stderr = commandResult.stderr.trim();
|
|
27
|
+
if (stderr) {
|
|
28
|
+
return stderr;
|
|
29
|
+
}
|
|
30
|
+
const stdout = commandResult.stdout.trim();
|
|
31
|
+
if (stdout) {
|
|
32
|
+
return stdout;
|
|
33
|
+
}
|
|
34
|
+
return `exit status ${commandResult.status}`;
|
|
35
|
+
}
|
|
36
|
+
function validateCleanWorkingTree(repositoryRoot, commandRunner) {
|
|
37
|
+
const statusOutput = runCommand(repositoryRoot, commandRunner, "git", ["status", "--porcelain"], "working tree status check");
|
|
38
|
+
if (!statusOutput) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
throw new CreatePullRequestError(`Expected working tree to be clean before pull request creation. Got ${statusOutput}.`);
|
|
42
|
+
}
|
|
43
|
+
function validateSemanticCommitTitles(repositoryRoot, commandRunner, base) {
|
|
44
|
+
const commitTitleOutput = runCommand(repositoryRoot, commandRunner, "git", ["log", "--format=%s", `${base}..HEAD`], "pull request commit title discovery");
|
|
45
|
+
const commitTitles = commitTitleOutput.split("\n").map((commitTitle) => commitTitle.trim()).filter(Boolean);
|
|
46
|
+
const invalidCommitTitle = commitTitles.find((commitTitle) => !semanticCommitTitlePattern.test(commitTitle));
|
|
47
|
+
if (!invalidCommitTitle) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
throw new CreatePullRequestError(`Expected semantic commit title format <type>(<scope>): <short summary>. Got ${invalidCommitTitle}.`);
|
|
51
|
+
}
|
|
52
|
+
function readCurrentBranch(repositoryRoot, commandRunner) {
|
|
53
|
+
const branchName = runCommand(repositoryRoot, commandRunner, "git", ["branch", "--show-current"], "current branch discovery");
|
|
54
|
+
if (branchName) {
|
|
55
|
+
return branchName;
|
|
56
|
+
}
|
|
57
|
+
throw new CreatePullRequestError("Expected current branch name to be non-empty. Got empty text.");
|
|
58
|
+
}
|
|
59
|
+
function isMissingUpstreamResult(commandResult) {
|
|
60
|
+
if (commandResult.errorMessage) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return readCommandFailureMessage(commandResult).includes("no upstream");
|
|
64
|
+
}
|
|
65
|
+
function readUpstreamLookupFailureMessage(commandResult) {
|
|
66
|
+
if (commandResult.errorMessage) {
|
|
67
|
+
return commandResult.errorMessage;
|
|
68
|
+
}
|
|
69
|
+
return readCommandFailureMessage(commandResult);
|
|
70
|
+
}
|
|
71
|
+
function pushBranchWhenUpstreamIsMissing(repositoryRoot, commandRunner, branchName) {
|
|
72
|
+
const upstreamResult = commandRunner.run("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], repositoryRoot);
|
|
73
|
+
if (upstreamResult.status === 0) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!isMissingUpstreamResult(upstreamResult)) {
|
|
77
|
+
throw new CreatePullRequestError(`Expected upstream lookup to succeed or report missing upstream. Got ${readUpstreamLookupFailureMessage(upstreamResult)}.`);
|
|
78
|
+
}
|
|
79
|
+
runCommand(repositoryRoot, commandRunner, "git", ["push", "-u", "origin", branchName], "branch push");
|
|
80
|
+
}
|
|
81
|
+
function createPullRequestBody(request) {
|
|
82
|
+
return [
|
|
83
|
+
"## Problem",
|
|
84
|
+
normalizeRequiredText(request.problem, "problem"),
|
|
85
|
+
"",
|
|
86
|
+
"## Solution",
|
|
87
|
+
normalizeRequiredText(request.solution, "solution"),
|
|
88
|
+
"",
|
|
89
|
+
"## Acceptance Criteria",
|
|
90
|
+
normalizeRequiredText(request.acceptanceCriteria, "acceptanceCriteria"),
|
|
91
|
+
"",
|
|
92
|
+
"## Architecture and software design",
|
|
93
|
+
normalizeRequiredText(request.architectureAndSoftwareDesign, "architectureAndSoftwareDesign"),
|
|
94
|
+
].join("\n");
|
|
95
|
+
}
|
|
96
|
+
export function createDraftPullRequest(repositoryRoot, request, commandRunner) {
|
|
97
|
+
const base = normalizeRequiredText(request.base, "base");
|
|
98
|
+
const title = normalizeRequiredText(request.title, "title");
|
|
99
|
+
const body = createPullRequestBody(request);
|
|
100
|
+
validateCleanWorkingTree(repositoryRoot, commandRunner);
|
|
101
|
+
validateSemanticCommitTitles(repositoryRoot, commandRunner, base);
|
|
102
|
+
const branchName = readCurrentBranch(repositoryRoot, commandRunner);
|
|
103
|
+
pushBranchWhenUpstreamIsMissing(repositoryRoot, commandRunner, branchName);
|
|
104
|
+
return runCommand(repositoryRoot, commandRunner, "gh", [
|
|
105
|
+
"pr",
|
|
106
|
+
"create",
|
|
107
|
+
"--draft",
|
|
108
|
+
"--base",
|
|
109
|
+
base,
|
|
110
|
+
"--title",
|
|
111
|
+
title,
|
|
112
|
+
"--body",
|
|
113
|
+
body,
|
|
114
|
+
], "draft pull request creation");
|
|
115
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CommandRunner } from "../source-control/changed-files.js";
|
|
2
|
+
export declare const PULL_REQUEST_FEEDBACK_TOOL_NAME = "nt_skillz_pr_feedback";
|
|
3
|
+
export declare class PullRequestFeedbackError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
export interface PullRequestFeedbackRequest {
|
|
7
|
+
repositoryRoot: string;
|
|
8
|
+
pullRequestNumber: string;
|
|
9
|
+
pullRequestUrl: string;
|
|
10
|
+
}
|
|
11
|
+
export interface PullRequestFeedbackDependencies {
|
|
12
|
+
commandRunner?: CommandRunner;
|
|
13
|
+
}
|
|
14
|
+
export declare function readPullRequestFeedback(request: PullRequestFeedbackRequest, dependencies?: PullRequestFeedbackDependencies): string;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { childProcessCommandRunner } from "../source-control/changed-files.js";
|
|
5
|
+
export const PULL_REQUEST_FEEDBACK_TOOL_NAME = "nt_skillz_pr_feedback";
|
|
6
|
+
const pullRequestViewSchema = z.object({
|
|
7
|
+
baseRefName: z.string(),
|
|
8
|
+
headRefName: z.string(),
|
|
9
|
+
id: z.string().min(1),
|
|
10
|
+
number: z.number().int(),
|
|
11
|
+
url: z.string().min(1),
|
|
12
|
+
});
|
|
13
|
+
const reviewThreadSchema = z.object({
|
|
14
|
+
id: z.string().min(1),
|
|
15
|
+
isOutdated: z.boolean(),
|
|
16
|
+
isResolved: z.boolean(),
|
|
17
|
+
line: z.number().int().nullable(),
|
|
18
|
+
path: z.string().min(1),
|
|
19
|
+
comments: z.object({
|
|
20
|
+
pageInfo: z.object({
|
|
21
|
+
hasNextPage: z.boolean(),
|
|
22
|
+
}),
|
|
23
|
+
nodes: z.array(z.object({
|
|
24
|
+
author: z.object({
|
|
25
|
+
login: z.string().min(1),
|
|
26
|
+
}),
|
|
27
|
+
body: z.string(),
|
|
28
|
+
createdAt: z.string().min(1),
|
|
29
|
+
diffHunk: z.string(),
|
|
30
|
+
url: z.string().min(1),
|
|
31
|
+
})),
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
const reviewThreadsResponseSchema = z.object({
|
|
35
|
+
node: z.object({
|
|
36
|
+
reviewThreads: z.object({
|
|
37
|
+
pageInfo: z.object({
|
|
38
|
+
endCursor: z.string().nullable(),
|
|
39
|
+
hasNextPage: z.boolean(),
|
|
40
|
+
}),
|
|
41
|
+
nodes: z.array(reviewThreadSchema),
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
export class PullRequestFeedbackError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function normalizeRequiredText(value, fieldName) {
|
|
51
|
+
const normalizedValue = value.trim();
|
|
52
|
+
if (normalizedValue) {
|
|
53
|
+
return normalizedValue;
|
|
54
|
+
}
|
|
55
|
+
throw new PullRequestFeedbackError(`Expected ${fieldName} to be non-empty. Got blank text.`);
|
|
56
|
+
}
|
|
57
|
+
function ensureSuccessfulCommand(commandResult, commandDescription) {
|
|
58
|
+
if (commandResult.errorMessage) {
|
|
59
|
+
throw new PullRequestFeedbackError(`Expected ${commandDescription} to run. Got ${commandResult.errorMessage}.`);
|
|
60
|
+
}
|
|
61
|
+
if (commandResult.status === 0) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const failureOutput = commandResult.stderr.trim() || commandResult.stdout.trim();
|
|
65
|
+
if (failureOutput) {
|
|
66
|
+
throw new PullRequestFeedbackError(`Expected ${commandDescription} to succeed. Got ${failureOutput}.`);
|
|
67
|
+
}
|
|
68
|
+
throw new PullRequestFeedbackError(`Expected ${commandDescription} to succeed. Got exit status ${commandResult.status}.`);
|
|
69
|
+
}
|
|
70
|
+
function parseJsonWithSchema(jsonText, schema, commandDescription) {
|
|
71
|
+
const parsedJson = parseJson(jsonText, commandDescription);
|
|
72
|
+
const parsedResult = schema.safeParse(parsedJson);
|
|
73
|
+
if (parsedResult.success) {
|
|
74
|
+
return parsedResult.data;
|
|
75
|
+
}
|
|
76
|
+
throw new PullRequestFeedbackError(`Expected ${commandDescription} to return valid JSON. Got ${parsedResult.error.message}.`);
|
|
77
|
+
}
|
|
78
|
+
function parseJson(jsonText, commandDescription) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(jsonText);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new PullRequestFeedbackError(`Expected ${commandDescription} to return JSON. Got malformed JSON.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function readPullRequestView(repositoryRoot, pullRequestNumber, commandRunner) {
|
|
87
|
+
const commandResult = commandRunner.run("gh", [
|
|
88
|
+
"pr",
|
|
89
|
+
"view",
|
|
90
|
+
pullRequestNumber,
|
|
91
|
+
"--json",
|
|
92
|
+
"id,number,url,headRefName,baseRefName",
|
|
93
|
+
"--jq",
|
|
94
|
+
".",
|
|
95
|
+
], repositoryRoot);
|
|
96
|
+
ensureSuccessfulCommand(commandResult, "GitHub pull request lookup");
|
|
97
|
+
return parseJsonWithSchema(commandResult.stdout, pullRequestViewSchema, "GitHub pull request lookup");
|
|
98
|
+
}
|
|
99
|
+
function createReviewThreadsQuery() {
|
|
100
|
+
return [
|
|
101
|
+
"query($pullRequestId: ID!, $threadCursor: String) {",
|
|
102
|
+
" node(id: $pullRequestId) {",
|
|
103
|
+
" ... on PullRequest {",
|
|
104
|
+
" reviewThreads(first: 100, after: $threadCursor) {",
|
|
105
|
+
" pageInfo { endCursor hasNextPage }",
|
|
106
|
+
" nodes {",
|
|
107
|
+
" id",
|
|
108
|
+
" isOutdated",
|
|
109
|
+
" isResolved",
|
|
110
|
+
" line",
|
|
111
|
+
" path",
|
|
112
|
+
" comments(first: 100) {",
|
|
113
|
+
" pageInfo { hasNextPage }",
|
|
114
|
+
" nodes {",
|
|
115
|
+
" author { login }",
|
|
116
|
+
" body",
|
|
117
|
+
" createdAt",
|
|
118
|
+
" diffHunk",
|
|
119
|
+
" url",
|
|
120
|
+
" }",
|
|
121
|
+
" }",
|
|
122
|
+
" }",
|
|
123
|
+
" }",
|
|
124
|
+
" }",
|
|
125
|
+
" }",
|
|
126
|
+
"}",
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
function readReviewThreadPage(repositoryRoot, pullRequestId, threadCursor, commandRunner) {
|
|
130
|
+
const commandArguments = [
|
|
131
|
+
"api",
|
|
132
|
+
"graphql",
|
|
133
|
+
"-f",
|
|
134
|
+
`pullRequestId=${pullRequestId}`,
|
|
135
|
+
"-f",
|
|
136
|
+
`query=${createReviewThreadsQuery()}`,
|
|
137
|
+
];
|
|
138
|
+
if (threadCursor) {
|
|
139
|
+
commandArguments.splice(4, 0, "-f", `threadCursor=${threadCursor}`);
|
|
140
|
+
}
|
|
141
|
+
const commandResult = commandRunner.run("gh", commandArguments, repositoryRoot);
|
|
142
|
+
ensureSuccessfulCommand(commandResult, "GitHub pull request review thread lookup");
|
|
143
|
+
return parseJsonWithSchema(commandResult.stdout, reviewThreadsResponseSchema, "GitHub pull request review thread lookup");
|
|
144
|
+
}
|
|
145
|
+
function readAllReviewThreads(repositoryRoot, pullRequestId, commandRunner, threadCursor = null, previousReviewThreads = []) {
|
|
146
|
+
const response = readReviewThreadPage(repositoryRoot, pullRequestId, threadCursor, commandRunner);
|
|
147
|
+
const reviewThreadPage = response.node.reviewThreads;
|
|
148
|
+
const reviewThreads = [...previousReviewThreads, ...reviewThreadPage.nodes];
|
|
149
|
+
if (reviewThreadPage.pageInfo.hasNextPage) {
|
|
150
|
+
if (reviewThreadPage.pageInfo.endCursor === null) {
|
|
151
|
+
throw new PullRequestFeedbackError("Expected GitHub review thread page cursor. Got null.");
|
|
152
|
+
}
|
|
153
|
+
return readAllReviewThreads(repositoryRoot, pullRequestId, commandRunner, reviewThreadPage.pageInfo.endCursor, reviewThreads);
|
|
154
|
+
}
|
|
155
|
+
return reviewThreads;
|
|
156
|
+
}
|
|
157
|
+
function ensurePullRequestMatchesRequest(pullRequestView, pullRequestNumber, pullRequestUrl) {
|
|
158
|
+
if (String(pullRequestView.number) !== pullRequestNumber) {
|
|
159
|
+
throw new PullRequestFeedbackError(`Expected GitHub PR number ${pullRequestNumber}. Got ${pullRequestView.number}.`);
|
|
160
|
+
}
|
|
161
|
+
if (pullRequestView.url !== pullRequestUrl) {
|
|
162
|
+
throw new PullRequestFeedbackError(`Expected GitHub PR URL ${pullRequestUrl}. Got ${pullRequestView.url}.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function ensureCompleteThreadComments(reviewThreads) {
|
|
166
|
+
const incompleteThread = reviewThreads.find((reviewThread) => reviewThread.comments.pageInfo.hasNextPage);
|
|
167
|
+
if (!incompleteThread) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
throw new PullRequestFeedbackError(`Expected review thread ${incompleteThread.id} to have at most 100 comments. Got more than 100 comments.`);
|
|
171
|
+
}
|
|
172
|
+
function languageForPath(filePath) {
|
|
173
|
+
const extension = path.extname(filePath);
|
|
174
|
+
if (extension === ".ts" || extension === ".tsx")
|
|
175
|
+
return "ts";
|
|
176
|
+
if (extension === ".js" || extension === ".jsx")
|
|
177
|
+
return "js";
|
|
178
|
+
if (extension === ".json")
|
|
179
|
+
return "json";
|
|
180
|
+
if (extension === ".md")
|
|
181
|
+
return "md";
|
|
182
|
+
if (extension === ".yml" || extension === ".yaml")
|
|
183
|
+
return "yaml";
|
|
184
|
+
return "text";
|
|
185
|
+
}
|
|
186
|
+
function quoteMarkdown(value) {
|
|
187
|
+
return value.split("\n").map((line) => `> ${line}`).join("\n");
|
|
188
|
+
}
|
|
189
|
+
function formatDiffHunk(diffHunk) {
|
|
190
|
+
if (diffHunk.trim()) {
|
|
191
|
+
return ["```diff", diffHunk, "```"].join("\n");
|
|
192
|
+
}
|
|
193
|
+
return "No diff hunk returned by GitHub.";
|
|
194
|
+
}
|
|
195
|
+
function resolveReviewThreadFilePath(repositoryRoot, filePath) {
|
|
196
|
+
const absoluteRepositoryRoot = path.resolve(repositoryRoot);
|
|
197
|
+
const absoluteFilePath = path.resolve(absoluteRepositoryRoot, filePath);
|
|
198
|
+
const relativeFilePath = path.relative(absoluteRepositoryRoot, absoluteFilePath);
|
|
199
|
+
if (!relativeFilePath.startsWith("..") && !path.isAbsolute(relativeFilePath)) {
|
|
200
|
+
return absoluteFilePath;
|
|
201
|
+
}
|
|
202
|
+
throw new PullRequestFeedbackError(`Expected review thread path to stay inside repository root. Got ${filePath}.`);
|
|
203
|
+
}
|
|
204
|
+
function readCodeExcerpt(repositoryRoot, filePath, lineNumber) {
|
|
205
|
+
const absoluteFilePath = resolveReviewThreadFilePath(repositoryRoot, filePath);
|
|
206
|
+
if (!fs.existsSync(absoluteFilePath)) {
|
|
207
|
+
return `Current local file not found: ${filePath}`;
|
|
208
|
+
}
|
|
209
|
+
if (lineNumber === null) {
|
|
210
|
+
return "No current line number returned by GitHub.";
|
|
211
|
+
}
|
|
212
|
+
const fileLines = fs.readFileSync(absoluteFilePath, "utf8").split("\n");
|
|
213
|
+
const startLine = Math.max(1, lineNumber - 5);
|
|
214
|
+
const endLine = Math.min(fileLines.length, lineNumber + 5);
|
|
215
|
+
const excerptLines = fileLines.slice(startLine - 1, endLine);
|
|
216
|
+
const numberedLines = excerptLines.map((line, index) => `${startLine + index}: ${line}`);
|
|
217
|
+
return [`\`\`\`${languageForPath(filePath)}`, ...numberedLines, "```"].join("\n");
|
|
218
|
+
}
|
|
219
|
+
function formatComments(reviewThread) {
|
|
220
|
+
return reviewThread.comments.nodes.map((comment, index) => [
|
|
221
|
+
`#### Comment ${index + 1}`,
|
|
222
|
+
`- Comment URL: ${comment.url}`,
|
|
223
|
+
`- Author: ${comment.author.login}`,
|
|
224
|
+
`- Created: ${comment.createdAt}`,
|
|
225
|
+
"- Full comment:",
|
|
226
|
+
quoteMarkdown(comment.body),
|
|
227
|
+
].join("\n")).join("\n\n");
|
|
228
|
+
}
|
|
229
|
+
function readLatestDiffHunk(reviewThread) {
|
|
230
|
+
const latestComment = reviewThread.comments.nodes.at(-1);
|
|
231
|
+
if (latestComment) {
|
|
232
|
+
return latestComment.diffHunk;
|
|
233
|
+
}
|
|
234
|
+
throw new PullRequestFeedbackError(`Expected review thread ${reviewThread.id} to contain at least one comment. Got 0.`);
|
|
235
|
+
}
|
|
236
|
+
function formatReviewLine(lineNumber) {
|
|
237
|
+
if (lineNumber === null) {
|
|
238
|
+
return "outdated";
|
|
239
|
+
}
|
|
240
|
+
return String(lineNumber);
|
|
241
|
+
}
|
|
242
|
+
function formatReviewThread(repositoryRoot, reviewThread) {
|
|
243
|
+
return [
|
|
244
|
+
`## Thread ${reviewThread.id}`,
|
|
245
|
+
"",
|
|
246
|
+
"### Reviewer feedback",
|
|
247
|
+
formatComments(reviewThread),
|
|
248
|
+
"",
|
|
249
|
+
"### Review context",
|
|
250
|
+
`- File: ${reviewThread.path}`,
|
|
251
|
+
`- Line: ${formatReviewLine(reviewThread.line)}`,
|
|
252
|
+
`- Outdated: ${reviewThread.isOutdated ? "yes" : "no"}`,
|
|
253
|
+
"- Diff hunk:",
|
|
254
|
+
formatDiffHunk(readLatestDiffHunk(reviewThread)),
|
|
255
|
+
"",
|
|
256
|
+
"### Current local code",
|
|
257
|
+
readCodeExcerpt(repositoryRoot, reviewThread.path, reviewThread.line),
|
|
258
|
+
].join("\n");
|
|
259
|
+
}
|
|
260
|
+
export function readPullRequestFeedback(request, dependencies = {}) {
|
|
261
|
+
const pullRequestNumber = normalizeRequiredText(request.pullRequestNumber, "pull request number");
|
|
262
|
+
const pullRequestUrl = normalizeRequiredText(request.pullRequestUrl, "pull request URL");
|
|
263
|
+
const commandRunner = dependencies.commandRunner ?? childProcessCommandRunner;
|
|
264
|
+
const pullRequestView = readPullRequestView(request.repositoryRoot, pullRequestNumber, commandRunner);
|
|
265
|
+
ensurePullRequestMatchesRequest(pullRequestView, pullRequestNumber, pullRequestUrl);
|
|
266
|
+
const reviewThreads = readAllReviewThreads(request.repositoryRoot, pullRequestView.id, commandRunner);
|
|
267
|
+
ensureCompleteThreadComments(reviewThreads);
|
|
268
|
+
const unresolvedReviewThreads = reviewThreads.filter((reviewThread) => !reviewThread.isResolved);
|
|
269
|
+
return [
|
|
270
|
+
"# Pull Request Feedback",
|
|
271
|
+
"",
|
|
272
|
+
`- PR number: ${pullRequestView.number}`,
|
|
273
|
+
`- PR URL: ${pullRequestView.url}`,
|
|
274
|
+
`- Head branch: ${pullRequestView.headRefName}`,
|
|
275
|
+
`- Base branch: ${pullRequestView.baseRefName}`,
|
|
276
|
+
`- Unresolved review threads: ${unresolvedReviewThreads.length}`,
|
|
277
|
+
"",
|
|
278
|
+
...unresolvedReviewThreads.map((reviewThread) => formatReviewThread(request.repositoryRoot, reviewThread)),
|
|
279
|
+
].join("\n");
|
|
280
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface CommandRunResult {
|
|
2
|
+
status: number | null;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
errorMessage?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CommandRunner {
|
|
8
|
+
run(executable: string, commandArguments: string[], workingDirectory: string): CommandRunResult;
|
|
9
|
+
}
|
|
10
|
+
export interface PullRequestChangedFileRequest {
|
|
11
|
+
repositoryRoot: string;
|
|
12
|
+
pullRequest?: string;
|
|
13
|
+
base?: string;
|
|
14
|
+
head?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class PullRequestFileResolutionError extends Error {
|
|
17
|
+
constructor(message: string);
|
|
18
|
+
}
|
|
19
|
+
export declare const childProcessCommandRunner: CommandRunner;
|
|
20
|
+
export declare function resolvePullRequestChangedFiles(request: PullRequestChangedFileRequest, commandRunner?: CommandRunner): string[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
export class PullRequestFileResolutionError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export const childProcessCommandRunner = {
|
|
8
|
+
run(executable, commandArguments, workingDirectory) {
|
|
9
|
+
const commandResult = spawnSync(executable, commandArguments, {
|
|
10
|
+
cwd: workingDirectory,
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
});
|
|
13
|
+
if (commandResult.error) {
|
|
14
|
+
return {
|
|
15
|
+
status: commandResult.status,
|
|
16
|
+
stdout: commandResult.stdout,
|
|
17
|
+
stderr: commandResult.stderr,
|
|
18
|
+
errorMessage: commandResult.error.message,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
status: commandResult.status,
|
|
23
|
+
stdout: commandResult.stdout,
|
|
24
|
+
stderr: commandResult.stderr,
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
function normalizeOptionalText(value) {
|
|
29
|
+
if (typeof value !== "string") {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const trimmedValue = value.trim();
|
|
33
|
+
if (!trimmedValue) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return trimmedValue;
|
|
37
|
+
}
|
|
38
|
+
function ensureSuccessfulCommand(commandResult, commandDescription) {
|
|
39
|
+
if (commandResult.errorMessage) {
|
|
40
|
+
throw new PullRequestFileResolutionError(`Expected ${commandDescription} to run. Got ${commandResult.errorMessage}.`);
|
|
41
|
+
}
|
|
42
|
+
if (commandResult.status === 0) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const failureOutput = commandResult.stderr.trim() || commandResult.stdout.trim();
|
|
46
|
+
if (failureOutput) {
|
|
47
|
+
throw new PullRequestFileResolutionError(`Expected ${commandDescription} to succeed. Got ${failureOutput}.`);
|
|
48
|
+
}
|
|
49
|
+
throw new PullRequestFileResolutionError(`Expected ${commandDescription} to succeed. Got exit status ${commandResult.status}.`);
|
|
50
|
+
}
|
|
51
|
+
function splitChangedFileOutput(output) {
|
|
52
|
+
return [...new Set(output.split("\n").map((changedPath) => changedPath.trim()).filter(Boolean))];
|
|
53
|
+
}
|
|
54
|
+
function readGitHubPullRequestFiles(repositoryRoot, pullRequest, commandRunner) {
|
|
55
|
+
const commandResult = commandRunner.run("gh", ["pr", "diff", pullRequest, "--name-only"], repositoryRoot);
|
|
56
|
+
ensureSuccessfulCommand(commandResult, "GitHub pull request file discovery");
|
|
57
|
+
return splitChangedFileOutput(commandResult.stdout);
|
|
58
|
+
}
|
|
59
|
+
function readGitChangedFiles(repositoryRoot, baseReference, headReference, commandRunner) {
|
|
60
|
+
const commandResult = commandRunner.run("git", ["diff", "--name-only", "--diff-filter=ACMR", `${baseReference}...${headReference}`], repositoryRoot);
|
|
61
|
+
ensureSuccessfulCommand(commandResult, "git changed-file discovery");
|
|
62
|
+
return splitChangedFileOutput(commandResult.stdout);
|
|
63
|
+
}
|
|
64
|
+
export function resolvePullRequestChangedFiles(request, commandRunner = childProcessCommandRunner) {
|
|
65
|
+
const pullRequest = normalizeOptionalText(request.pullRequest);
|
|
66
|
+
if (pullRequest) {
|
|
67
|
+
return readGitHubPullRequestFiles(request.repositoryRoot, pullRequest, commandRunner);
|
|
68
|
+
}
|
|
69
|
+
const baseReference = normalizeOptionalText(request.base);
|
|
70
|
+
const headReference = normalizeOptionalText(request.head);
|
|
71
|
+
if (baseReference && headReference) {
|
|
72
|
+
return readGitChangedFiles(request.repositoryRoot, baseReference, headReference, commandRunner);
|
|
73
|
+
}
|
|
74
|
+
if (baseReference) {
|
|
75
|
+
return readGitChangedFiles(request.repositoryRoot, baseReference, "HEAD", commandRunner);
|
|
76
|
+
}
|
|
77
|
+
throw new PullRequestFileResolutionError("Expected pull request identifier or base reference for pr-review mode.");
|
|
78
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface CoverageCommandSpec {
|
|
2
|
+
executable: string;
|
|
3
|
+
commandArguments: string[];
|
|
4
|
+
workingDirectory: string;
|
|
5
|
+
coverageSummaryRoot: string;
|
|
6
|
+
coverageSummaryFilePath: string;
|
|
7
|
+
}
|
|
8
|
+
interface CoverageCommandRequest {
|
|
9
|
+
repositoryRoot: string;
|
|
10
|
+
filePath: string;
|
|
11
|
+
packageRoot: string;
|
|
12
|
+
packageRelativeFilePath: string;
|
|
13
|
+
reportsDirectory: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function createVitestCoverageCommandSpec(request: CoverageCommandRequest): CoverageCommandSpec;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
class VitestCoverageCommandResolutionError extends Error {
|
|
4
|
+
}
|
|
5
|
+
function formatMissingVitestCommandMessage(checkedBinaryPaths, checkedLockFilePaths) {
|
|
6
|
+
return [
|
|
7
|
+
"Expected Vitest binary in one of:",
|
|
8
|
+
...checkedBinaryPaths.map((binaryPath) => `- ${binaryPath}`),
|
|
9
|
+
"or workspace package manager lockfile in one of:",
|
|
10
|
+
...checkedLockFilePaths.map((lockFilePath) => `- ${lockFilePath}`),
|
|
11
|
+
].join("\n");
|
|
12
|
+
}
|
|
13
|
+
function collectVitestBinaryPathsFromDirectory(repositoryRoot, currentDirectory, checkedBinaryPaths) {
|
|
14
|
+
const binaryPath = path.join(currentDirectory, "node_modules", ".bin", "vitest");
|
|
15
|
+
const nextCheckedBinaryPaths = [...checkedBinaryPaths, binaryPath];
|
|
16
|
+
if (currentDirectory === repositoryRoot) {
|
|
17
|
+
return nextCheckedBinaryPaths;
|
|
18
|
+
}
|
|
19
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
20
|
+
return collectVitestBinaryPathsFromDirectory(repositoryRoot, parentDirectory, nextCheckedBinaryPaths);
|
|
21
|
+
}
|
|
22
|
+
function resolveVitestBinaryPath(checkedBinaryPaths) {
|
|
23
|
+
return checkedBinaryPaths.find((binaryPath) => fs.existsSync(binaryPath));
|
|
24
|
+
}
|
|
25
|
+
function createVitestArguments(coverageFilePath, reportsDirectory) {
|
|
26
|
+
return [
|
|
27
|
+
"--run",
|
|
28
|
+
"--coverage.enabled",
|
|
29
|
+
`--coverage.include=${coverageFilePath}`,
|
|
30
|
+
"--coverage.reporter=json-summary",
|
|
31
|
+
"--coverage.reporter=text",
|
|
32
|
+
`--coverage.reportsDirectory=${reportsDirectory}`,
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
function createDirectVitestCommandSpec(vitestBinaryPath, request) {
|
|
36
|
+
return {
|
|
37
|
+
executable: vitestBinaryPath,
|
|
38
|
+
commandArguments: createVitestArguments(request.packageRelativeFilePath, request.reportsDirectory),
|
|
39
|
+
workingDirectory: request.packageRoot,
|
|
40
|
+
coverageSummaryRoot: request.packageRoot,
|
|
41
|
+
coverageSummaryFilePath: request.packageRelativeFilePath,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function getCheckedLockFilePaths(repositoryRoot) {
|
|
45
|
+
return [
|
|
46
|
+
path.join(repositoryRoot, "yarn.lock"),
|
|
47
|
+
path.join(repositoryRoot, "pnpm-lock.yaml"),
|
|
48
|
+
path.join(repositoryRoot, "package-lock.json"),
|
|
49
|
+
path.join(repositoryRoot, "npm-shrinkwrap.json"),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
function createWorkspaceCommandSpec(executable, commandArguments, request) {
|
|
53
|
+
return {
|
|
54
|
+
executable,
|
|
55
|
+
commandArguments,
|
|
56
|
+
workingDirectory: request.repositoryRoot,
|
|
57
|
+
coverageSummaryRoot: request.repositoryRoot,
|
|
58
|
+
coverageSummaryFilePath: request.filePath,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function createWorkspaceVitestCommandSpec(request) {
|
|
62
|
+
const vitestArguments = createVitestArguments(request.filePath, request.reportsDirectory);
|
|
63
|
+
if (fs.existsSync(path.join(request.repositoryRoot, "yarn.lock"))) {
|
|
64
|
+
return createWorkspaceCommandSpec("yarn", ["vitest", ...vitestArguments], request);
|
|
65
|
+
}
|
|
66
|
+
if (fs.existsSync(path.join(request.repositoryRoot, "pnpm-lock.yaml"))) {
|
|
67
|
+
return createWorkspaceCommandSpec("pnpm", ["exec", "vitest", ...vitestArguments], request);
|
|
68
|
+
}
|
|
69
|
+
if (fs.existsSync(path.join(request.repositoryRoot, "package-lock.json")) || fs.existsSync(path.join(request.repositoryRoot, "npm-shrinkwrap.json"))) {
|
|
70
|
+
return createWorkspaceCommandSpec("npm", ["exec", "--", "vitest", ...vitestArguments], request);
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
export function createVitestCoverageCommandSpec(request) {
|
|
75
|
+
const checkedBinaryPaths = collectVitestBinaryPathsFromDirectory(path.resolve(request.repositoryRoot), path.resolve(request.packageRoot), []);
|
|
76
|
+
const vitestBinaryPath = resolveVitestBinaryPath(checkedBinaryPaths);
|
|
77
|
+
if (vitestBinaryPath !== undefined) {
|
|
78
|
+
return createDirectVitestCommandSpec(vitestBinaryPath, request);
|
|
79
|
+
}
|
|
80
|
+
const workspaceVitestCommandSpec = createWorkspaceVitestCommandSpec(request);
|
|
81
|
+
if (workspaceVitestCommandSpec !== undefined) {
|
|
82
|
+
return workspaceVitestCommandSpec;
|
|
83
|
+
}
|
|
84
|
+
throw new VitestCoverageCommandResolutionError(formatMissingVitestCommandMessage(checkedBinaryPaths, getCheckedLockFilePaths(request.repositoryRoot)));
|
|
85
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type CommandRunner } from "../source-control/changed-files.js";
|
|
2
|
+
export declare const VITEST_COVERAGE_TOOL_NAME = "nt_skillz_vitest_coverage";
|
|
3
|
+
interface CoverageRequest {
|
|
4
|
+
repositoryRoot?: string;
|
|
5
|
+
files?: string[];
|
|
6
|
+
mode?: string;
|
|
7
|
+
pullRequest?: string;
|
|
8
|
+
base?: string;
|
|
9
|
+
head?: string;
|
|
10
|
+
}
|
|
11
|
+
interface CoverageMetric {
|
|
12
|
+
total: number;
|
|
13
|
+
covered: number;
|
|
14
|
+
skipped: number;
|
|
15
|
+
pct: number;
|
|
16
|
+
}
|
|
17
|
+
interface FileCoverageSummary {
|
|
18
|
+
lines: CoverageMetric;
|
|
19
|
+
statements: CoverageMetric;
|
|
20
|
+
functions: CoverageMetric;
|
|
21
|
+
branches: CoverageMetric;
|
|
22
|
+
}
|
|
23
|
+
interface CoverageExecutionEnvironment {
|
|
24
|
+
commandRunner: CommandRunner;
|
|
25
|
+
temporaryDirectoryCreator: (prefix: string) => string;
|
|
26
|
+
temporaryDirectoryRemover: (directoryPath: string) => void;
|
|
27
|
+
}
|
|
28
|
+
interface CoveragePassed {
|
|
29
|
+
status: "passed";
|
|
30
|
+
filePath: string;
|
|
31
|
+
summary: FileCoverageSummary;
|
|
32
|
+
}
|
|
33
|
+
interface CoverageFailed {
|
|
34
|
+
status: "failed";
|
|
35
|
+
filePath: string;
|
|
36
|
+
summary: FileCoverageSummary;
|
|
37
|
+
commandOutput: string;
|
|
38
|
+
}
|
|
39
|
+
interface CoverageErrored {
|
|
40
|
+
status: "errored";
|
|
41
|
+
filePath: string;
|
|
42
|
+
message: string;
|
|
43
|
+
commandOutput: string;
|
|
44
|
+
}
|
|
45
|
+
type CoverageFileResult = CoveragePassed | CoverageFailed | CoverageErrored;
|
|
46
|
+
export declare function runVitestCoverageReview(request: CoverageRequest, environment?: CoverageExecutionEnvironment): Promise<{
|
|
47
|
+
markdown: string;
|
|
48
|
+
results: CoverageFileResult[];
|
|
49
|
+
}>;
|
|
50
|
+
export {};
|