@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.
@@ -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 proc = Bun.spawnSync(cmd, { cwd });
18
- if (proc.exitCode !== 0) {
19
- const stderr = proc.stderr.toString().trim();
20
- throw new Error(`Command failed (exit ${proc.exitCode}): ${cmd.join(" ")}${stderr ? `: ${stderr}` : ""}`);
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.toString();
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
- const workingDiff = (await exec(["git", "diff", "HEAD"], gitRoot)).trim();
40
- if (workingDiff.length > 0) {
41
- gitDiff = workingDiff;
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
- gitDiff = (await exec(["git", "diff", "HEAD~1..HEAD"], gitRoot)).trim();
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
+ }