pr-prism 1.1.3 → 1.1.6
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/.github/dependabot.yml +26 -0
- package/.github/workflows/ci.yml +1 -0
- package/.github/workflows/dependabot-auto-merge.yml +4 -10
- package/package.json +12 -6
- package/scripts/lib/sanitize.test.ts +545 -0
- package/scripts/lib/sanitize.ts +152 -0
- package/scripts/lib/scrape-issues.ts +204 -0
- package/scripts/lib/shared.test.ts +20 -0
- package/scripts/lib/shared.ts +74 -0
- package/scripts/pr-prism.ts +260 -0
- package/scripts/resolve-pr-threads.ts +59 -12
- package/scripts/scrape-pr-reviews.ts +47 -42
- package/.github/workflows/release.yml +0 -123
- package/pr-reviews/.gitkeep +0 -0
- package/pr-reviews/.scraped-ids.json +0 -34
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export const KNOWN_BOTS = [
|
|
2
|
+
"github-actions",
|
|
3
|
+
"dependabot",
|
|
4
|
+
"coderabbitai",
|
|
5
|
+
"changeset-bot",
|
|
6
|
+
"codeantai",
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export const NOISE_DOMAINS = [
|
|
10
|
+
"twitter.com/intent",
|
|
11
|
+
"x.com/intent",
|
|
12
|
+
"reddit.com/submit",
|
|
13
|
+
"linkedin.com/sharing",
|
|
14
|
+
"app.codeant.ai",
|
|
15
|
+
"codeant.ai/feedback",
|
|
16
|
+
"cubic.dev",
|
|
17
|
+
"qodo.ai/wp-content",
|
|
18
|
+
"dashboard.gitguardian.com",
|
|
19
|
+
"blog.gitguardian.com",
|
|
20
|
+
"docs.gitguardian.com",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function isBot(login: string): boolean {
|
|
24
|
+
const l = login.toLowerCase();
|
|
25
|
+
return l.endsWith("[bot]") || KNOWN_BOTS.some((b) => l.includes(b));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function renderSuggestions(body: string): string {
|
|
29
|
+
return body.replace(/```suggestion\r?\n([\s\S]*?)```/g, (_, code: string) => {
|
|
30
|
+
const lines = code
|
|
31
|
+
.trimEnd()
|
|
32
|
+
.split(/\r?\n/)
|
|
33
|
+
.map((l: string) => `+ ${l}`)
|
|
34
|
+
.join("\n");
|
|
35
|
+
return `\n**SUGGESTED CHANGE:**\n\`\`\`diff\n${lines}\n\`\`\`\n`;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const NOISE_LINE_PATTERNS = [
|
|
40
|
+
/thanks for using/i,
|
|
41
|
+
/free for open-source/i,
|
|
42
|
+
/help us grow by sharing/i,
|
|
43
|
+
/Generated by.*CodeAnt/i,
|
|
44
|
+
/CodeAnt AI (?:is reviewing|finished reviewing)/i,
|
|
45
|
+
/Check back in a few minutes/i,
|
|
46
|
+
/AI review agent is analyzing/i,
|
|
47
|
+
/Looking for bugs\?/i,
|
|
48
|
+
/Validate the correctness of the flagged issue/i,
|
|
49
|
+
/This is a comment left during a code review/i,
|
|
50
|
+
/implement it and please make it concise/i,
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export function stripNoise(body: string): string {
|
|
54
|
+
let result = body;
|
|
55
|
+
|
|
56
|
+
result = result.replace(/<!--[\s\S]*?-->/g, "");
|
|
57
|
+
result = result.replace(/<picture>[\s\S]*?<\/picture>/gi, "");
|
|
58
|
+
result = result.replace(/<sub>[\s\S]*?<\/sub>/gi, "");
|
|
59
|
+
result = result.replace(/<sup>[\s\S]*?<\/sup>/gi, "");
|
|
60
|
+
result = result.replace(/<s>[\s\S]*?<\/s>/gi, "");
|
|
61
|
+
result = result.replace(/<img\s[^>]*\/?>/gi, "");
|
|
62
|
+
result = result.replace(/<source\s[^>]*\/?>/gi, "");
|
|
63
|
+
|
|
64
|
+
result = result.replace(/```mermaid[\s\S]*?```/g, "");
|
|
65
|
+
|
|
66
|
+
result = result.replace(/^.*\u24D8.*$/gm, "");
|
|
67
|
+
|
|
68
|
+
result = result.replace(
|
|
69
|
+
/<a\s[^>]*href=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/a>/gi,
|
|
70
|
+
(match, url: string) => {
|
|
71
|
+
if (NOISE_DOMAINS.some((d) => url.includes(d))) return "";
|
|
72
|
+
const innerText = match.replace(/<[^>]*>/g, "").trim();
|
|
73
|
+
if (!innerText) return "";
|
|
74
|
+
return innerText;
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
result = result.replace(/<\/?(details|summary|pre|table|thead|tbody)\b[^>]*>/gi, "");
|
|
79
|
+
result = result.replace(/<tr\b[^>]*>/gi, "");
|
|
80
|
+
result = result.replace(/<\/tr>/gi, "\n");
|
|
81
|
+
result = result.replace(/<\/?(td|th)\b[^>]*>/gi, " ");
|
|
82
|
+
result = result.replace(/<\/?(h[1-6])\b[^>]*>/gi, "");
|
|
83
|
+
result = result.replace(/<br\s*\/?>/gi, "\n");
|
|
84
|
+
result = result.replace(/<hr\s*\/?>/gi, "");
|
|
85
|
+
result = result.replace(/<\/?(b|i|strong|em|ins)\s*>/gi, "");
|
|
86
|
+
result = result.replace(/<code>([\s\S]*?)<\/code>/gi, "`$1`");
|
|
87
|
+
|
|
88
|
+
result = result.replace(/<\/?file context>/gi, "");
|
|
89
|
+
result = result.replace(/<\/?comment>/gi, "");
|
|
90
|
+
result = result.replace(/<violation\b[^>]*>/gi, "");
|
|
91
|
+
result = result.replace(/<\/violation>/gi, "");
|
|
92
|
+
result = result.replace(/<file\b[^>]*>/gi, "");
|
|
93
|
+
result = result.replace(/<\/file>/gi, "");
|
|
94
|
+
|
|
95
|
+
// Decode HTML entities
|
|
96
|
+
result = result.replace(/&/g, "&");
|
|
97
|
+
result = result.replace(/</g, "<");
|
|
98
|
+
result = result.replace(/>/g, ">");
|
|
99
|
+
result = result.replace(/"/g, '"');
|
|
100
|
+
result = result.replace(/ /g, " ");
|
|
101
|
+
|
|
102
|
+
// Strip block quote markers
|
|
103
|
+
result = result.replace(/^>\s?/gm, "");
|
|
104
|
+
|
|
105
|
+
// Strip emoji
|
|
106
|
+
result = result.replace(/[\p{Extended_Pictographic}\u200D\uFE0F]/gu, "");
|
|
107
|
+
|
|
108
|
+
// Strip GitHub blob/diff/files links — path is already in comment header
|
|
109
|
+
result = result.replace(
|
|
110
|
+
/`?\[([^\]]*)\]\(https:\/\/github\.com\/[^)]*(?:\/files#diff-|\/blob\/|\/pull\/)[^)]*\)`?/g,
|
|
111
|
+
"$1",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Strip noise-domain markdown links
|
|
115
|
+
result = result.replace(
|
|
116
|
+
/\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g,
|
|
117
|
+
(match, _text: string, url: string) =>
|
|
118
|
+
NOISE_DOMAINS.some((d) => url.includes(d)) ? "" : match,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Strip promotional/status noise lines
|
|
122
|
+
result = result.replace(/^.*$/gm, (line) =>
|
|
123
|
+
NOISE_LINE_PATTERNS.some((p) => p.test(line)) ? "" : line,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Strip standalone section headers (noise when content follows)
|
|
127
|
+
result = result.replace(
|
|
128
|
+
/^\s*(?:Agent prompt|Walkthroughs|View more|Prompt for AI agents?|Prompt for AI Agent|Review Summary by Qodo|Code Review by Qodo|Nitpicks|Recommended areas for review)\s*(?:\([^)]*\))?\s*$/gim,
|
|
129
|
+
"",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
result = result.replace(/^[\s\u00B7|\u2014\-]+$/gm, "");
|
|
133
|
+
result = result.replace(/\n{3,}/g, "\n\n");
|
|
134
|
+
|
|
135
|
+
return result.trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ~4 chars per token is the standard LLM approximation
|
|
139
|
+
const CHARS_PER_TOKEN = 4;
|
|
140
|
+
|
|
141
|
+
export function estimateTokens(text: string): number {
|
|
142
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function formatTokenSummary(rawChars: number, cleanedChars: number): string {
|
|
146
|
+
const rawTokens = Math.ceil(rawChars / CHARS_PER_TOKEN);
|
|
147
|
+
const cleanedTokens = Math.ceil(cleanedChars / CHARS_PER_TOKEN);
|
|
148
|
+
const removedTokens = Math.max(0, rawTokens - cleanedTokens);
|
|
149
|
+
const pctSaved = rawTokens > 0 ? Math.round((removedTokens / rawTokens) * 100) : 0;
|
|
150
|
+
|
|
151
|
+
return `Tokens: ${rawTokens.toLocaleString()} raw > ${cleanedTokens.toLocaleString()} clean (${pctSaved}% saved)`;
|
|
152
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { defineWizard, runWizard } from "grimoire-wizard";
|
|
5
|
+
import { detectRepo, parseGhUrl, loadCache, saveCache, fetchGraphQL, ensureGitignore, run } from "./shared.js";
|
|
6
|
+
import { isBot, stripNoise, formatTokenSummary } from "./sanitize.js";
|
|
7
|
+
|
|
8
|
+
const OUT_DIR = "issues";
|
|
9
|
+
const CACHE_FILE = join(OUT_DIR, ".scraped-ids.json");
|
|
10
|
+
|
|
11
|
+
const ISSUE_GRAPHQL = `
|
|
12
|
+
query($owner: String!, $repo: String!, $issueNumber: Int!) {
|
|
13
|
+
repository(owner: $owner, name: $repo) {
|
|
14
|
+
issue(number: $issueNumber) {
|
|
15
|
+
title
|
|
16
|
+
body
|
|
17
|
+
state
|
|
18
|
+
author { login }
|
|
19
|
+
labels(first: 20) { nodes { name } }
|
|
20
|
+
comments(first: 100) {
|
|
21
|
+
nodes { databaseId author { login } body }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}`.trim();
|
|
26
|
+
|
|
27
|
+
interface IssueComment { databaseId: number; author: { login: string }; body: string; }
|
|
28
|
+
interface IssueLabel { name: string; }
|
|
29
|
+
|
|
30
|
+
interface IssuePayload {
|
|
31
|
+
data: {
|
|
32
|
+
repository: {
|
|
33
|
+
issue: {
|
|
34
|
+
title: string;
|
|
35
|
+
body: string;
|
|
36
|
+
state: string;
|
|
37
|
+
author: { login: string };
|
|
38
|
+
labels: { nodes: IssueLabel[] };
|
|
39
|
+
comments: { nodes: IssueComment[] };
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface IssueListItem { number: number; title: string; author: { login: string }; labels: { name: string }[]; }
|
|
46
|
+
|
|
47
|
+
const VALID_STATES = new Set(["open", "closed", "all"]);
|
|
48
|
+
|
|
49
|
+
function listIssues(owner: string, repo: string, state: string): IssueListItem[] {
|
|
50
|
+
if (!VALID_STATES.has(state)) {
|
|
51
|
+
console.error(`Invalid --state "${state}". Must be: open, closed, or all`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
const out = run(`gh issue list --repo ${owner}/${repo} --state ${state} --json number,title,author,labels --limit 50`);
|
|
55
|
+
return JSON.parse(out) as IssueListItem[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function selectIssue(issues: IssueListItem[]): Promise<number> {
|
|
59
|
+
if (issues.length === 0) { console.log("No issues found."); process.exit(0); }
|
|
60
|
+
|
|
61
|
+
const config = defineWizard({
|
|
62
|
+
meta: { name: "pr-prism" },
|
|
63
|
+
steps: [
|
|
64
|
+
{
|
|
65
|
+
id: "issue",
|
|
66
|
+
type: "select" as const,
|
|
67
|
+
message: "Select an issue:",
|
|
68
|
+
options: issues.map((i) => {
|
|
69
|
+
const labels = i.labels.map((l) => l.name).join(", ");
|
|
70
|
+
const labelSuffix = labels ? ` [${labels}]` : "";
|
|
71
|
+
return {
|
|
72
|
+
value: String(i.number),
|
|
73
|
+
label: `#${i.number} ${i.title} (${i.author.login})${labelSuffix}`,
|
|
74
|
+
};
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const answers = await runWizard(config, { onCancel: () => process.exit(0) });
|
|
81
|
+
const selected = answers?.issue;
|
|
82
|
+
if (!selected) process.exit(0);
|
|
83
|
+
return parseInt(selected as string, 10);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface AppendResult { text: string; rawChars: number; cleanedChars: number; }
|
|
87
|
+
|
|
88
|
+
function appendBody(
|
|
89
|
+
out: string,
|
|
90
|
+
body: string,
|
|
91
|
+
login: string,
|
|
92
|
+
issueNumber: number,
|
|
93
|
+
labels: IssueLabel[],
|
|
94
|
+
): AppendResult {
|
|
95
|
+
const rawChars = body.length;
|
|
96
|
+
const cleaned = stripNoise(body).trim();
|
|
97
|
+
if (!cleaned) return { text: out, rawChars, cleanedChars: 0 };
|
|
98
|
+
const labelStr = labels.length > 0
|
|
99
|
+
? " · labels: " + labels.map((l) => `\`${l.name}\``).join(", ")
|
|
100
|
+
: "";
|
|
101
|
+
const labelTag = labels.length > 0
|
|
102
|
+
? " [" + labels.map((l) => l.name).join(", ") + "]"
|
|
103
|
+
: "";
|
|
104
|
+
const appended = `ISSUE #${issueNumber} ${login}${labelTag}\n${cleaned}\n\n---\n\n`;
|
|
105
|
+
return { text: out + appended, rawChars, cleanedChars: cleaned.length };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function appendComment(out: string, c: IssueComment): AppendResult {
|
|
109
|
+
const rawChars = c.body.length;
|
|
110
|
+
const cleaned = stripNoise(c.body).trim();
|
|
111
|
+
if (!cleaned) return { text: out, rawChars, cleanedChars: 0 };
|
|
112
|
+
const appended = `${c.author.login} #${c.databaseId}\n${cleaned}\n\n---\n\n`;
|
|
113
|
+
return { text: out + appended, rawChars, cleanedChars: cleaned.length };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseRepoFlag(flagArgs: string[]): { owner: string; repo: string } | null {
|
|
117
|
+
const idx = flagArgs.indexOf("--repo");
|
|
118
|
+
if (idx === -1 || !flagArgs[idx + 1]) return null;
|
|
119
|
+
const parts = flagArgs[idx + 1].split("/");
|
|
120
|
+
if (parts.length !== 2) return null;
|
|
121
|
+
return { owner: parts[0], repo: parts[1] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function scrapeIssue(args: string[]): Promise<void> {
|
|
125
|
+
const repoFlag = parseRepoFlag(args);
|
|
126
|
+
const stateIdx = args.indexOf("--state");
|
|
127
|
+
const state = stateIdx !== -1 && args[stateIdx + 1] ? args[stateIdx + 1] : "open";
|
|
128
|
+
const skipFlags = new Set(["--state", "--repo"]);
|
|
129
|
+
const positional = args.filter((a, i) => !skipFlags.has(a) && !skipFlags.has(args[i - 1]));
|
|
130
|
+
|
|
131
|
+
let owner: string, repo: string, issueNumber: number;
|
|
132
|
+
|
|
133
|
+
const firstArg = positional[0];
|
|
134
|
+
if (firstArg?.startsWith("http")) {
|
|
135
|
+
const parsed = parseGhUrl(firstArg);
|
|
136
|
+
if (parsed.type !== "issues") {
|
|
137
|
+
console.error(`Expected an issue URL, got a PR URL: ${firstArg}`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
owner = parsed.owner;
|
|
141
|
+
repo = parsed.repo;
|
|
142
|
+
issueNumber = parsed.number;
|
|
143
|
+
} else if (firstArg && /^\d+$/.test(firstArg)) {
|
|
144
|
+
({ owner, repo } = repoFlag ?? detectRepo());
|
|
145
|
+
issueNumber = parseInt(firstArg, 10);
|
|
146
|
+
} else {
|
|
147
|
+
({ owner, repo } = repoFlag ?? detectRepo());
|
|
148
|
+
const issues = listIssues(owner, repo, state);
|
|
149
|
+
console.log(`\nFound ${issues.length} ${state} issue(s) in ${owner}/${repo}\n`);
|
|
150
|
+
issueNumber = await selectIssue(issues);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log(`\nFetching issue #${issueNumber} from ${owner}/${repo}…`);
|
|
154
|
+
const payload = fetchGraphQL<IssuePayload>(ISSUE_GRAPHQL, { owner, repo, issueNumber });
|
|
155
|
+
const issue = payload.data.repository.issue;
|
|
156
|
+
if (!issue) {
|
|
157
|
+
console.error(`Issue #${issueNumber} not found in ${owner}/${repo}.`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
162
|
+
ensureGitignore(["issues/new-*.md"]);
|
|
163
|
+
|
|
164
|
+
const cache = loadCache(CACHE_FILE);
|
|
165
|
+
const header = `Issue #${issueNumber} -- ${owner}/${repo}\n\n`;
|
|
166
|
+
let body = "";
|
|
167
|
+
let count = 0;
|
|
168
|
+
let totalRawChars = 0;
|
|
169
|
+
let totalCleanedChars = 0;
|
|
170
|
+
|
|
171
|
+
const bodyKey = `issue-body-${issueNumber}`;
|
|
172
|
+
if (!cache.has(bodyKey) && issue.body?.trim()) {
|
|
173
|
+
const result = appendBody(body, issue.body, issue.author.login, issueNumber, issue.labels.nodes);
|
|
174
|
+
body = result.text;
|
|
175
|
+
totalRawChars += result.rawChars;
|
|
176
|
+
totalCleanedChars += result.cleanedChars;
|
|
177
|
+
cache.add(bodyKey);
|
|
178
|
+
if (result.cleanedChars > 0) count++;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const c of issue.comments.nodes) {
|
|
182
|
+
const key = String(c.databaseId);
|
|
183
|
+
if (isBot(c.author.login) || !c.body.trim()) { cache.add(key); continue; }
|
|
184
|
+
if (cache.has(key)) continue;
|
|
185
|
+
const result = appendComment(body, c);
|
|
186
|
+
if (result.cleanedChars === 0) { cache.add(key); continue; }
|
|
187
|
+
body = result.text;
|
|
188
|
+
totalRawChars += result.rawChars;
|
|
189
|
+
totalCleanedChars += result.cleanedChars;
|
|
190
|
+
cache.add(key);
|
|
191
|
+
count++;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const tokenSummary = count > 0
|
|
195
|
+
? formatTokenSummary(totalRawChars, totalCleanedChars) + "\n\n"
|
|
196
|
+
: "";
|
|
197
|
+
const output = header + tokenSummary + body;
|
|
198
|
+
|
|
199
|
+
saveCache(CACHE_FILE, cache);
|
|
200
|
+
const outFile = join(OUT_DIR, `new-${new Date().toISOString().replace(/[:.]/g, "-")}.md`);
|
|
201
|
+
writeFileSync(outFile, output, "utf-8");
|
|
202
|
+
|
|
203
|
+
console.log(count > 0 ? `\n✅ ${count} new entry(s) → ${outFile}` : `\n✅ No new comments since last run. → ${outFile}`);
|
|
204
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseGhUrl } from "./shared.js";
|
|
3
|
+
|
|
4
|
+
describe("parseGhUrl", () => {
|
|
5
|
+
it("parses PR URLs", () => {
|
|
6
|
+
const result = parseGhUrl("https://github.com/octocat/repo/pull/42");
|
|
7
|
+
expect(result).toEqual({ owner: "octocat", repo: "repo", type: "pull", number: 42 });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("parses issue URLs", () => {
|
|
11
|
+
const result = parseGhUrl("https://github.com/YosefHayim/extensions/issues/15");
|
|
12
|
+
expect(result).toEqual({ owner: "YosefHayim", repo: "extensions", type: "issues", number: 15 });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("handles URLs with trailing content", () => {
|
|
16
|
+
const result = parseGhUrl("https://github.com/org/project/issues/99#issuecomment-123");
|
|
17
|
+
expect(result.number).toBe(99);
|
|
18
|
+
expect(result.type).toBe("issues");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
export interface IdCache { seen: string[]; }
|
|
7
|
+
|
|
8
|
+
export function run(cmd: string): string {
|
|
9
|
+
return execSync(cmd, { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function detectRepo(): { owner: string; repo: string } {
|
|
13
|
+
try {
|
|
14
|
+
const remote = run("git remote get-url origin");
|
|
15
|
+
const m = remote.match(/github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
|
16
|
+
if (!m) throw new Error();
|
|
17
|
+
return { owner: m[1], repo: m[2] };
|
|
18
|
+
} catch {
|
|
19
|
+
console.error("Could not detect GitHub repo. Run from a GitHub repo or pass a URL.");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseGhUrl(url: string): { owner: string; repo: string; type: "pull" | "issues"; number: number } {
|
|
25
|
+
const m = url.match(/github\.com\/([^/]+)\/([^/]+)\/(pull|issues)\/(\d+)/);
|
|
26
|
+
if (!m) { console.error(`Invalid GitHub URL: ${url}`); process.exit(1); }
|
|
27
|
+
return { owner: m[1], repo: m[2], type: m[3] as "pull" | "issues", number: parseInt(m[4], 10) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadCache(cacheFile: string): Set<string> {
|
|
31
|
+
if (!existsSync(cacheFile)) return new Set();
|
|
32
|
+
try { return new Set((JSON.parse(readFileSync(cacheFile, "utf-8")) as IdCache).seen); } catch { return new Set(); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function saveCache(cacheFile: string, seen: Set<string>): void {
|
|
36
|
+
writeFileSync(cacheFile, JSON.stringify({ seen: [...seen] }, null, 2), "utf-8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function fetchGraphQL<T>(query: string, variables: Record<string, unknown>): T {
|
|
40
|
+
const reqFile = join(tmpdir(), `.pr-prism-req-${Date.now()}.json`);
|
|
41
|
+
writeFileSync(reqFile, JSON.stringify({ query, variables }), "utf-8");
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(run(`gh api https://api.github.com/graphql --input "${reqFile}"`)) as T;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error("gh API failed. Is gh authenticated? Run: gh auth login");
|
|
46
|
+
console.error((err as Error).message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
} finally {
|
|
49
|
+
unlinkSync(reqFile);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ensureGitignore(entries: string[]): void {
|
|
54
|
+
const GITIGNORE = ".gitignore";
|
|
55
|
+
const MARKER = "# pr-prism";
|
|
56
|
+
const BLOCK = ["", MARKER, ...entries].join("\n");
|
|
57
|
+
|
|
58
|
+
if (!existsSync(GITIGNORE)) {
|
|
59
|
+
writeFileSync(GITIGNORE, BLOCK.trimStart() + "\n", "utf-8");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const content = readFileSync(GITIGNORE, "utf-8");
|
|
64
|
+
if (content.includes(MARKER)) {
|
|
65
|
+
const missing = entries.filter((entry) => !content.includes(entry));
|
|
66
|
+
if (missing.length === 0) return;
|
|
67
|
+
const tail = content.endsWith("\n") ? "" : "\n";
|
|
68
|
+
writeFileSync(GITIGNORE, content + tail + missing.join("\n") + "\n", "utf-8");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const suffix = content.endsWith("\n") ? "" : "\n";
|
|
73
|
+
writeFileSync(GITIGNORE, content + suffix + BLOCK + "\n", "utf-8");
|
|
74
|
+
}
|