@nt-ai-lab/opencode-skillz 0.3.15 → 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/dist/commands/dont-stop/hooks.js +4 -10
- package/dist/git-workflow-gates.d.ts +21 -0
- package/dist/git-workflow-gates.js +103 -0
- package/dist/plugin-registry/agents.js +0 -4
- package/dist/plugin-registry/commands.js +0 -2
- package/dist/plugin-registry/index.js +29 -2
- 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/{lint-review.d.ts → infra/lint/review.d.ts} +1 -1
- package/dist/tools/{lint-review.js → infra/lint/review.js} +1 -1
- 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/vitest-coverage/command.d.ts +16 -0
- package/dist/tools/infra/vitest-coverage/command.js +85 -0
- package/dist/tools/{vitest-coverage.d.ts → infra/vitest-coverage/review.d.ts} +1 -18
- package/dist/tools/{vitest-coverage.js → infra/vitest-coverage/review.js} +15 -52
- 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 -17
- package/dist/tools/lint.js +29 -18
- 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 +4 -3
- package/scripts/check-tools-folder-boundary.mjs +78 -0
- package/scripts/install-git-hooks.mjs +42 -4
- package/scripts/lint-ts.mjs +32 -7
- package/scripts/living-architecture-eslint.config.mjs +2 -2
- package/scripts/no-generic-names-eslint-rule.mjs +2 -24
- package/scripts/no-generic-names-eslint-rule.mjs.d.ts +28 -0
- /package/dist/tools/{pull-request-files.d.ts → infra/source-control/changed-files.d.ts} +0 -0
- /package/dist/tools/{pull-request-files.js → infra/source-control/changed-files.js} +0 -0
|
@@ -1,16 +1,43 @@
|
|
|
1
|
+
import process from "node:process";
|
|
1
2
|
import { createDontStopHooks } from "../commands/dont-stop/index.js";
|
|
3
|
+
import { createGitWorkflowGate, } from "../git-workflow-gates.js";
|
|
4
|
+
import { createPullRequestTool, } from "../tools/create-pr-tool.js";
|
|
2
5
|
import { LINT_TOOL_NAME, lintTool, } from "../tools/lint.js";
|
|
3
|
-
import {
|
|
6
|
+
import { PULL_REQUEST_FEEDBACK_TOOL_NAME, pullRequestFeedbackTool, } from "../tools/pull-request-feedback-tool.js";
|
|
7
|
+
import { VITEST_COVERAGE_TOOL_NAME, vitestCoverageTool, } from "../tools/vitest-coverage-tool.js";
|
|
4
8
|
import { registerAgents } from "./agents.js";
|
|
5
9
|
import { registerCommands } from "./commands.js";
|
|
10
|
+
import { childProcessCommandRunner } from "../tools/infra/source-control/changed-files.js";
|
|
11
|
+
function readLintedFilePaths(request) {
|
|
12
|
+
if (!Array.isArray(request.files)) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return request.files.filter((filePath) => typeof filePath === "string");
|
|
16
|
+
}
|
|
17
|
+
function createGateAwareLintTool(gate) {
|
|
18
|
+
return {
|
|
19
|
+
...lintTool,
|
|
20
|
+
async execute(request, context) {
|
|
21
|
+
const result = await lintTool.execute(request, context);
|
|
22
|
+
gate.recordLintedFiles(readLintedFilePaths(request));
|
|
23
|
+
return result;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
6
27
|
export function createPluginRegistry(input, pluginRoot) {
|
|
7
28
|
const dontStopHooks = createDontStopHooks(input.client);
|
|
29
|
+
const gitWorkflowGate = createGitWorkflowGate(input.worktree ?? process.cwd(), childProcessCommandRunner);
|
|
8
30
|
return {
|
|
9
31
|
...dontStopHooks,
|
|
10
32
|
tool: {
|
|
11
|
-
|
|
33
|
+
nt_skillz_create_pr: createPullRequestTool,
|
|
34
|
+
[LINT_TOOL_NAME]: createGateAwareLintTool(gitWorkflowGate),
|
|
35
|
+
[PULL_REQUEST_FEEDBACK_TOOL_NAME]: pullRequestFeedbackTool,
|
|
12
36
|
[VITEST_COVERAGE_TOOL_NAME]: vitestCoverageTool,
|
|
13
37
|
},
|
|
38
|
+
"tool.execute.before": async (hookInput, hookOutput) => {
|
|
39
|
+
gitWorkflowGate.beforeToolExecution(hookInput, hookOutput);
|
|
40
|
+
},
|
|
14
41
|
config: async (config) => {
|
|
15
42
|
config.command ??= {};
|
|
16
43
|
config.agent ??= {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ToolDefinition } from "@opencode-ai/plugin";
|
|
2
|
+
import type { CommandRunner } from "../git-workflow-gates.js";
|
|
3
|
+
export declare function createPullRequestToolWithRunner(commandRunner: CommandRunner): ToolDefinition;
|
|
4
|
+
export declare const createPullRequestTool: ToolDefinition;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { createDraftPullRequest } from "./infra/pull-request/create-draft-pull-request.js";
|
|
3
|
+
import { childProcessCommandRunner } from "./infra/source-control/changed-files.js";
|
|
4
|
+
export function createPullRequestToolWithRunner(commandRunner) {
|
|
5
|
+
return tool({
|
|
6
|
+
description: "Create a validated draft pull request with required OpenCode workflow gates.",
|
|
7
|
+
args: {
|
|
8
|
+
base: tool.schema.string().describe("Base branch for the pull request."),
|
|
9
|
+
title: tool.schema.string().describe("Pull request title."),
|
|
10
|
+
problem: tool.schema.string().describe("Problem section content."),
|
|
11
|
+
solution: tool.schema.string().describe("Solution section content."),
|
|
12
|
+
acceptanceCriteria: tool.schema.string().describe("Acceptance Criteria section content."),
|
|
13
|
+
architectureAndSoftwareDesign: tool.schema.string().describe("Architecture and software design section content."),
|
|
14
|
+
},
|
|
15
|
+
async execute(request, context) {
|
|
16
|
+
return {
|
|
17
|
+
output: createDraftPullRequest(context.worktree, request, commandRunner),
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export const createPullRequestTool = createPullRequestToolWithRunner(childProcessCommandRunner);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface LintMessageWithRule {
|
|
2
|
+
ruleId: string | null;
|
|
3
|
+
}
|
|
4
|
+
interface LintResultWithMessages {
|
|
5
|
+
messages: readonly LintMessageWithRule[];
|
|
6
|
+
}
|
|
7
|
+
export declare function createLintFailureGuidance(lintResults: readonly LintResultWithMessages[]): string;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const genericLintFailureGuidance = [
|
|
2
|
+
"Lint remediation guidance:",
|
|
3
|
+
"- Do not sacrifice code quality or test coverage to satisfy lint rules.",
|
|
4
|
+
"- These rules are not objectives; they are signs that code needs to be split, simplified, or clarified.",
|
|
5
|
+
"- Fix the underlying design or test issue instead of deleting assertions, disabling rules, or reducing coverage.",
|
|
6
|
+
];
|
|
7
|
+
const targetedLintGuidance = [
|
|
8
|
+
{
|
|
9
|
+
ruleIds: ["vitest/max-expects"],
|
|
10
|
+
guidance: [
|
|
11
|
+
"Do not delete required assertions or weaken expected values to reduce assertion count.",
|
|
12
|
+
"If several assertions describe one observable result, prefer one whole-result assertion such as `toEqual`, `toStrictEqual`, `toMatchObject`, or a domain-specific matcher.",
|
|
13
|
+
"If the test verifies multiple behaviours, split it into separate tests with outcome-focused names.",
|
|
14
|
+
"Do not combine unrelated checks into one object assertion just to satisfy this rule.",
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
ruleIds: ["max-lines", "sonarjs/max-lines", "sonarjs/max-lines-per-function"],
|
|
19
|
+
guidance: [
|
|
20
|
+
"Do not delete required behavior, tests, setup, edge cases, or assertions to reduce line count.",
|
|
21
|
+
"Split by cohesive responsibility instead: production behavior, test fixture construction, assertions, adapters, or domain concepts.",
|
|
22
|
+
"For long spec files, extract fixture builders or split scenarios by behavior.",
|
|
23
|
+
"For long source files, extract named concepts that can be tested independently.",
|
|
24
|
+
"Do not move unrelated code into vague files such as utils, helpers, common, shared, or lib.",
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
ruleIds: [
|
|
29
|
+
"complexity",
|
|
30
|
+
"max-depth",
|
|
31
|
+
"sonarjs/cognitive-complexity",
|
|
32
|
+
"sonarjs/cyclomatic-complexity",
|
|
33
|
+
"sonarjs/nested-control-flow",
|
|
34
|
+
],
|
|
35
|
+
guidance: [
|
|
36
|
+
"Do not remove branches, states, error handling, or edge cases to reduce complexity.",
|
|
37
|
+
"Reduce complexity by making decisions easier to read without changing behavior.",
|
|
38
|
+
"Use guard clauses, early returns, named predicates, or discriminated unions where they clarify the code.",
|
|
39
|
+
"If complexity comes from multiple states, model those states directly instead of stacking conditionals.",
|
|
40
|
+
"Keep all existing behavior, tests, and edge cases intact.",
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
ruleIds: [
|
|
45
|
+
"@typescript-eslint/no-explicit-any",
|
|
46
|
+
"@typescript-eslint/no-unsafe-assignment",
|
|
47
|
+
"@typescript-eslint/no-unsafe-call",
|
|
48
|
+
"@typescript-eslint/no-unsafe-member-access",
|
|
49
|
+
"@typescript-eslint/no-unsafe-return",
|
|
50
|
+
"@typescript-eslint/consistent-type-assertions",
|
|
51
|
+
"@typescript-eslint/no-non-null-assertion",
|
|
52
|
+
"sonarjs/no-return-type-any",
|
|
53
|
+
],
|
|
54
|
+
guidance: [
|
|
55
|
+
"Do not replace `any` with `unknown as X`, non-null assertions, type assertions, or broader unsafe types.",
|
|
56
|
+
"Add precise types at the boundary where the value enters the system.",
|
|
57
|
+
"For external input, parse with Zod or an existing runtime validator.",
|
|
58
|
+
"For impossible states, change the type model instead of silencing the error.",
|
|
59
|
+
"Keep behavior unchanged and preserve existing validation.",
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
ruleIds: [
|
|
64
|
+
"@eslint-community/eslint-comments/no-use",
|
|
65
|
+
"no-inline-comments",
|
|
66
|
+
"sonarjs/no-commented-code",
|
|
67
|
+
"sonarjs/no-sonar-comments",
|
|
68
|
+
"@typescript-eslint/ban-ts-comment",
|
|
69
|
+
],
|
|
70
|
+
guidance: [
|
|
71
|
+
"Do not add code comments. They make the code noisy and are a workaround for poor code. Make code intention revealing so that comments are not necessary.",
|
|
72
|
+
"Do not add lint or TypeScript suppressions. The rules are non-negotiable. If the solution cannot be implemented while following the codebase rules, stop and ask for help.",
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
function isPresentRuleId(ruleId) {
|
|
77
|
+
return typeof ruleId === "string" && ruleId.length > 0;
|
|
78
|
+
}
|
|
79
|
+
function readTriggeredRuleIds(lintResults) {
|
|
80
|
+
return new Set(lintResults.flatMap((lintResult) => {
|
|
81
|
+
return lintResult.messages.map((message) => message.ruleId).filter(isPresentRuleId);
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
export function createLintFailureGuidance(lintResults) {
|
|
85
|
+
const triggeredRuleIds = readTriggeredRuleIds(lintResults);
|
|
86
|
+
const specificGuidance = targetedLintGuidance
|
|
87
|
+
.filter((targetedGuidance) => targetedGuidance.ruleIds.some((ruleId) => triggeredRuleIds.has(ruleId)))
|
|
88
|
+
.flatMap((targetedGuidance) => targetedGuidance.guidance);
|
|
89
|
+
if (specificGuidance.length === 0) {
|
|
90
|
+
return genericLintFailureGuidance.join("\n");
|
|
91
|
+
}
|
|
92
|
+
return [
|
|
93
|
+
...genericLintFailureGuidance,
|
|
94
|
+
"",
|
|
95
|
+
"Rule-specific guidance:",
|
|
96
|
+
...specificGuidance.map((guidance) => `- ${guidance}`),
|
|
97
|
+
].join("\n");
|
|
98
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { resolvePullRequestChangedFiles, } from "
|
|
3
|
+
import { resolvePullRequestChangedFiles, } from "../source-control/changed-files.js";
|
|
4
4
|
function normalizeTypeScriptFiles(filePaths) {
|
|
5
5
|
return filePaths.filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
|
|
6
6
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CommandRunner } from "../../../git-workflow-gates.js";
|
|
2
|
+
export declare const CREATE_PULL_REQUEST_TOOL_NAME = "nt_skillz_create_pr";
|
|
3
|
+
export interface CreatePullRequestRequest {
|
|
4
|
+
base: string;
|
|
5
|
+
title: string;
|
|
6
|
+
problem: string;
|
|
7
|
+
solution: string;
|
|
8
|
+
acceptanceCriteria: string;
|
|
9
|
+
architectureAndSoftwareDesign: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function createDraftPullRequest(repositoryRoot: string, request: CreatePullRequestRequest, commandRunner: CommandRunner): string;
|
|
@@ -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,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 {};
|