@demon-utils/playwright 0.1.6 → 0.1.7
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/dist/bin/demon-demo-init.js +56 -0
- package/dist/bin/demon-demo-init.js.map +10 -0
- package/dist/bin/demon-demo-review.js +187 -523
- package/dist/bin/demon-demo-review.js.map +7 -7
- package/dist/bin/demoon.js +1445 -0
- package/dist/bin/demoon.js.map +22 -0
- package/dist/bin/review-template.html +62 -0
- package/dist/github-issue.js +749 -0
- package/dist/github-issue.js.map +16 -0
- package/dist/index.js +1320 -867
- package/dist/index.js.map +16 -8
- package/dist/orchestrator.js +1421 -0
- package/dist/orchestrator.js.map +20 -0
- package/dist/review-generator.js +424 -0
- package/dist/review-generator.js.map +12 -0
- package/dist/review-template.html +62 -0
- package/package.json +11 -2
- package/src/bin/demon-demo-init.ts +59 -0
- package/src/bin/demon-demo-review.ts +19 -97
- package/src/bin/demoon.ts +140 -0
- package/src/feedback-server.ts +138 -0
- package/src/git-context.test.ts +68 -2
- package/src/git-context.ts +48 -9
- package/src/github-issue.test.ts +188 -0
- package/src/github-issue.ts +139 -0
- package/src/html-generator.e2e.test.ts +361 -80
- package/src/index.ts +9 -3
- package/src/orchestrator.test.ts +183 -0
- package/src/orchestrator.ts +341 -0
- package/src/review-generator.ts +221 -0
- package/src/review-types.ts +3 -0
- package/src/review.ts +13 -7
- package/src/html-generator.test.ts +0 -561
- package/src/html-generator.ts +0 -461
package/src/git-context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
|
|
3
4
|
export type ExecFn = (cmd: string[], cwd: string) => Promise<string>;
|
|
4
5
|
export type ReadFileFn = (path: string) => string;
|
|
@@ -11,15 +12,44 @@ export interface RepoContext {
|
|
|
11
12
|
export interface GetRepoContextOptions {
|
|
12
13
|
exec?: ExecFn;
|
|
13
14
|
readFile?: ReadFileFn;
|
|
15
|
+
diffBase?: string; // Base commit/branch for diff (auto-detected if not provided)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function detectDefaultBase(exec: ExecFn, gitRoot: string): Promise<string | null> {
|
|
19
|
+
// Get current branch name
|
|
20
|
+
let currentBranch: string;
|
|
21
|
+
try {
|
|
22
|
+
currentBranch = (await exec(["git", "rev-parse", "--abbrev-ref", "HEAD"], gitRoot)).trim();
|
|
23
|
+
} catch {
|
|
24
|
+
return null; // Detached HEAD or other issue
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// If on main/master, no base to compare against
|
|
28
|
+
if (currentBranch === "main" || currentBranch === "master") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Try to find main or master as base
|
|
33
|
+
for (const candidate of ["main", "master"]) {
|
|
34
|
+
try {
|
|
35
|
+
await exec(["git", "rev-parse", "--verify", candidate], gitRoot);
|
|
36
|
+
return candidate;
|
|
37
|
+
} catch {
|
|
38
|
+
// Branch doesn't exist, try next
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
14
43
|
}
|
|
15
44
|
|
|
16
45
|
const defaultExec: ExecFn = async (cmd, cwd) => {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
46
|
+
const [command, ...args] = cmd;
|
|
47
|
+
const proc = spawnSync(command!, args, { cwd, encoding: "utf-8" });
|
|
48
|
+
if (proc.status !== 0) {
|
|
49
|
+
const stderr = (proc.stderr ?? "").trim();
|
|
50
|
+
throw new Error(`Command failed (exit ${proc.status}): ${cmd.join(" ")}${stderr ? `: ${stderr}` : ""}`);
|
|
21
51
|
}
|
|
22
|
-
return proc.stdout
|
|
52
|
+
return proc.stdout ?? "";
|
|
23
53
|
};
|
|
24
54
|
|
|
25
55
|
const defaultReadFile: ReadFileFn = (path) => {
|
|
@@ -35,12 +65,21 @@ export async function getRepoContext(
|
|
|
35
65
|
|
|
36
66
|
const gitRoot = (await exec(["git", "rev-parse", "--show-toplevel"], demosDir)).trim();
|
|
37
67
|
|
|
68
|
+
// Determine the base for diff comparison
|
|
69
|
+
const diffBase = options?.diffBase ?? await detectDefaultBase(exec, gitRoot);
|
|
70
|
+
|
|
38
71
|
let gitDiff: string;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
gitDiff =
|
|
72
|
+
if (diffBase) {
|
|
73
|
+
// Use three-dot diff for merge-base comparison (shows changes on current branch)
|
|
74
|
+
gitDiff = (await exec(["git", "diff", `${diffBase}...HEAD`], gitRoot)).trim();
|
|
42
75
|
} else {
|
|
43
|
-
|
|
76
|
+
// Fallback: worktree diff or last commit
|
|
77
|
+
const workingDiff = (await exec(["git", "diff", "HEAD"], gitRoot)).trim();
|
|
78
|
+
if (workingDiff.length > 0) {
|
|
79
|
+
gitDiff = workingDiff;
|
|
80
|
+
} else {
|
|
81
|
+
gitDiff = (await exec(["git", "diff", "HEAD~1..HEAD"], gitRoot)).trim();
|
|
82
|
+
}
|
|
44
83
|
}
|
|
45
84
|
|
|
46
85
|
const lsOutput = (await exec(["git", "ls-files"], gitRoot)).trim();
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { GraphQLFn } from "./github-issue.ts";
|
|
4
|
+
import { fetchGitHubIssue } from "./github-issue.ts";
|
|
5
|
+
|
|
6
|
+
function mockGraphQL(response: unknown): GraphQLFn {
|
|
7
|
+
return (async () => response) as unknown as GraphQLFn;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("fetchGitHubIssue", () => {
|
|
11
|
+
test("fetches issue successfully", async () => {
|
|
12
|
+
const graphql = mockGraphQL({
|
|
13
|
+
repository: {
|
|
14
|
+
issue: {
|
|
15
|
+
number: 42,
|
|
16
|
+
title: "Fix the bug",
|
|
17
|
+
body: "This bug needs fixing",
|
|
18
|
+
state: "OPEN",
|
|
19
|
+
labels: {
|
|
20
|
+
nodes: [{ name: "bug" }, { name: "priority" }],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const issue = await fetchGitHubIssue(42, {
|
|
27
|
+
graphql,
|
|
28
|
+
owner: "test-owner",
|
|
29
|
+
repo: "test-repo",
|
|
30
|
+
token: "fake-token",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(issue.number).toBe(42);
|
|
34
|
+
expect(issue.title).toBe("Fix the bug");
|
|
35
|
+
expect(issue.body).toBe("This bug needs fixing");
|
|
36
|
+
expect(issue.state).toBe("open");
|
|
37
|
+
expect(issue.labels).toEqual(["bug", "priority"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("parses string issue ID", async () => {
|
|
41
|
+
const graphql = mockGraphQL({
|
|
42
|
+
repository: {
|
|
43
|
+
issue: {
|
|
44
|
+
number: 123,
|
|
45
|
+
title: "String ID test",
|
|
46
|
+
body: "",
|
|
47
|
+
state: "CLOSED",
|
|
48
|
+
labels: { nodes: [] },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const issue = await fetchGitHubIssue("123", {
|
|
54
|
+
graphql,
|
|
55
|
+
owner: "test-owner",
|
|
56
|
+
repo: "test-repo",
|
|
57
|
+
token: "fake-token",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(issue.number).toBe(123);
|
|
61
|
+
expect(issue.state).toBe("closed");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("handles null body", async () => {
|
|
65
|
+
const graphql = mockGraphQL({
|
|
66
|
+
repository: {
|
|
67
|
+
issue: {
|
|
68
|
+
number: 1,
|
|
69
|
+
title: "No body",
|
|
70
|
+
body: null,
|
|
71
|
+
state: "OPEN",
|
|
72
|
+
labels: { nodes: [] },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const issue = await fetchGitHubIssue(1, {
|
|
78
|
+
graphql,
|
|
79
|
+
owner: "test-owner",
|
|
80
|
+
repo: "test-repo",
|
|
81
|
+
token: "fake-token",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(issue.body).toBe("");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("handles empty labels", async () => {
|
|
88
|
+
const graphql = mockGraphQL({
|
|
89
|
+
repository: {
|
|
90
|
+
issue: {
|
|
91
|
+
number: 1,
|
|
92
|
+
title: "No labels",
|
|
93
|
+
body: "body",
|
|
94
|
+
state: "OPEN",
|
|
95
|
+
labels: { nodes: [] },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const issue = await fetchGitHubIssue(1, {
|
|
101
|
+
graphql,
|
|
102
|
+
owner: "test-owner",
|
|
103
|
+
repo: "test-repo",
|
|
104
|
+
token: "fake-token",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(issue.labels).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("throws on invalid issue ID", async () => {
|
|
111
|
+
const graphql = mockGraphQL({});
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
fetchGitHubIssue("not-a-number", {
|
|
115
|
+
graphql,
|
|
116
|
+
owner: "test-owner",
|
|
117
|
+
repo: "test-repo",
|
|
118
|
+
token: "fake-token",
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toThrow("Invalid issue ID: not-a-number");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("throws when no token provided and none in environment", async () => {
|
|
124
|
+
const originalToken = process.env["GITHUB_TOKEN"];
|
|
125
|
+
const originalGhToken = process.env["GH_TOKEN"];
|
|
126
|
+
delete process.env["GITHUB_TOKEN"];
|
|
127
|
+
delete process.env["GH_TOKEN"];
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await expect(
|
|
131
|
+
fetchGitHubIssue(1, {
|
|
132
|
+
owner: "test-owner",
|
|
133
|
+
repo: "test-repo",
|
|
134
|
+
}),
|
|
135
|
+
).rejects.toThrow("No GitHub token found");
|
|
136
|
+
} finally {
|
|
137
|
+
if (originalToken) process.env["GITHUB_TOKEN"] = originalToken;
|
|
138
|
+
if (originalGhToken) process.env["GH_TOKEN"] = originalGhToken;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("uses GITHUB_TOKEN from environment", async () => {
|
|
143
|
+
const originalToken = process.env["GITHUB_TOKEN"];
|
|
144
|
+
process.env["GITHUB_TOKEN"] = "env-token";
|
|
145
|
+
|
|
146
|
+
const graphql = mockGraphQL({
|
|
147
|
+
repository: {
|
|
148
|
+
issue: {
|
|
149
|
+
number: 1,
|
|
150
|
+
title: "Env token test",
|
|
151
|
+
body: "",
|
|
152
|
+
state: "OPEN",
|
|
153
|
+
labels: { nodes: [] },
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const issue = await fetchGitHubIssue(1, {
|
|
160
|
+
graphql,
|
|
161
|
+
owner: "test-owner",
|
|
162
|
+
repo: "test-repo",
|
|
163
|
+
});
|
|
164
|
+
expect(issue.number).toBe(1);
|
|
165
|
+
} finally {
|
|
166
|
+
if (originalToken) {
|
|
167
|
+
process.env["GITHUB_TOKEN"] = originalToken;
|
|
168
|
+
} else {
|
|
169
|
+
delete process.env["GITHUB_TOKEN"];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("throws when GraphQL request fails", async () => {
|
|
175
|
+
const graphql = (async () => {
|
|
176
|
+
throw new Error("GraphQL request failed: Not Found");
|
|
177
|
+
}) as unknown as GraphQLFn;
|
|
178
|
+
|
|
179
|
+
await expect(
|
|
180
|
+
fetchGitHubIssue(999999, {
|
|
181
|
+
graphql,
|
|
182
|
+
owner: "test-owner",
|
|
183
|
+
repo: "test-repo",
|
|
184
|
+
token: "fake-token",
|
|
185
|
+
}),
|
|
186
|
+
).rejects.toThrow("GraphQL request failed: Not Found");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { graphql as defaultGraphql } from "@octokit/graphql";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
export interface GitHubIssue {
|
|
5
|
+
number: number;
|
|
6
|
+
title: string;
|
|
7
|
+
body: string;
|
|
8
|
+
labels: string[];
|
|
9
|
+
state: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface GitHubIssueGraphQLResponse {
|
|
13
|
+
repository: {
|
|
14
|
+
issue: {
|
|
15
|
+
number: number;
|
|
16
|
+
title: string;
|
|
17
|
+
body: string;
|
|
18
|
+
state: string;
|
|
19
|
+
labels: {
|
|
20
|
+
nodes: Array<{ name: string }>;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type GraphQLFn = typeof defaultGraphql;
|
|
27
|
+
|
|
28
|
+
export interface FetchGitHubIssueOptions {
|
|
29
|
+
graphql?: GraphQLFn;
|
|
30
|
+
owner?: string;
|
|
31
|
+
repo?: string;
|
|
32
|
+
token?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ISSUE_QUERY = `
|
|
36
|
+
query($owner: String!, $repo: String!, $number: Int!) {
|
|
37
|
+
repository(owner: $owner, name: $repo) {
|
|
38
|
+
issue(number: $number) {
|
|
39
|
+
number
|
|
40
|
+
title
|
|
41
|
+
body
|
|
42
|
+
state
|
|
43
|
+
labels(first: 20) {
|
|
44
|
+
nodes {
|
|
45
|
+
name
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
function getRepoInfoFromGit(): { owner: string; repo: string } | null {
|
|
54
|
+
try {
|
|
55
|
+
const proc = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" });
|
|
56
|
+
if (proc.status !== 0 || !proc.stdout) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const url = proc.stdout.trim();
|
|
61
|
+
// Handle SSH format: git@github.com:owner/repo.git
|
|
62
|
+
const sshMatch = url.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
63
|
+
if (sshMatch) {
|
|
64
|
+
return { owner: sshMatch[1]!, repo: sshMatch[2]! };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Handle HTTPS format: https://github.com/owner/repo.git
|
|
68
|
+
const httpsMatch = url.match(/https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
69
|
+
if (httpsMatch) {
|
|
70
|
+
return { owner: httpsMatch[1]!, repo: httpsMatch[2]! };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getTokenFromEnvironment(): string | null {
|
|
80
|
+
return process.env["GITHUB_TOKEN"] ?? process.env["GH_TOKEN"] ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function fetchGitHubIssue(
|
|
84
|
+
issueId: string | number,
|
|
85
|
+
options?: FetchGitHubIssueOptions,
|
|
86
|
+
): Promise<GitHubIssue> {
|
|
87
|
+
const issueNumber = typeof issueId === "string" ? parseInt(issueId, 10) : issueId;
|
|
88
|
+
if (isNaN(issueNumber)) {
|
|
89
|
+
throw new Error(`Invalid issue ID: ${issueId}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Get owner/repo from options or detect from git
|
|
93
|
+
let owner = options?.owner;
|
|
94
|
+
let repo = options?.repo;
|
|
95
|
+
|
|
96
|
+
if (!owner || !repo) {
|
|
97
|
+
const detected = getRepoInfoFromGit();
|
|
98
|
+
if (!detected) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"Could not detect repository owner/name from git remote. " +
|
|
101
|
+
"Please provide owner and repo options, or run from a git repository with a GitHub origin."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
owner = owner ?? detected.owner;
|
|
105
|
+
repo = repo ?? detected.repo;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Get token from options or environment
|
|
109
|
+
const token = options?.token ?? getTokenFromEnvironment();
|
|
110
|
+
if (!token) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
"No GitHub token found. Please set GITHUB_TOKEN or GH_TOKEN environment variable, " +
|
|
113
|
+
"or provide token in options."
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Use provided graphql function or create one with the token
|
|
118
|
+
const graphqlFn = options?.graphql ?? defaultGraphql.defaults({
|
|
119
|
+
headers: {
|
|
120
|
+
authorization: `token ${token}`,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const response = await graphqlFn<GitHubIssueGraphQLResponse>(ISSUE_QUERY, {
|
|
125
|
+
owner,
|
|
126
|
+
repo,
|
|
127
|
+
number: issueNumber,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const issue = response.repository.issue;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
number: issue.number,
|
|
134
|
+
title: issue.title,
|
|
135
|
+
body: issue.body ?? "",
|
|
136
|
+
labels: issue.labels.nodes.map((l: { name: string }) => l.name),
|
|
137
|
+
state: issue.state.toLowerCase(),
|
|
138
|
+
};
|
|
139
|
+
}
|