issuewatcher-mcp 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mayank Kumar Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,229 @@
1
- # Temporary Holding Version
1
+ # IssueWatcher for your AI assistant
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Ask your AI assistant "What should I work on today?" and get back the GitHub issues that are actually worth your time, with the reason for each.**
4
+
5
+ IssueWatcher is an [MCP](https://modelcontextprotocol.io) server. It watches the repositories you starred and the open-source libraries your own projects depend on, ranks their newest issues, and explains every score. It works inside Claude, Cursor, VS Code and other apps that support MCP.
6
+
7
+ - **Pick up something to fix:** good first issues that are unassigned, fresh, and not already being fixed by someone else.
8
+ - **See what may break your projects:** problems reported in the libraries you use, matched to the versions you use.
9
+ - **Know why:** every issue comes with plain-language reasons, and `explain_issue` shows each signal and its points.
10
+ - **Safe:** every tool is read-only. It never comments on, assigns or changes anything on GitHub.
11
+ - **Free, and no AI key needed:** your assistant does the reasoning with its own model. IssueWatcher only needs a GitHub token.
12
+
13
+ > Status: early version (0.x). The ranking is rule-based and hand-tuned; it has been checked by reading real results, not yet against a large labelled data set. Feedback is very welcome: [open an issue](https://github.com/mac146/IssueWatcher/issues).
14
+
15
+ ## Quick start (about two minutes)
16
+
17
+ ### 1. Get a GitHub token
18
+
19
+ IssueWatcher reads your list of starred repositories, so it needs a token. Read-only is enough.
20
+
21
+ 1. Open <https://github.com/settings/personal-access-tokens/new> (GitHub, then Settings, Developer settings, Personal access tokens, Fine-grained tokens).
22
+ 2. **Token name:** `IssueWatcher`. **Expiration:** 90 days is a good choice (you can regenerate it).
23
+ 3. **Repository access:**
24
+ - *Public repositories (read-only)* is enough to watch your stars and public dependencies.
25
+ - To also read the `package.json` / `requirements.txt` of **your private repositories**, choose *All repositories* (or select the ones you want) and under *Repository permissions* set **Contents: Read-only**.
26
+ 4. Click **Generate token** and copy it (it starts with `github_pat_`). You will not be able to see it again.
27
+
28
+ A classic token (starts with `ghp_`) with the `public_repo` scope also works. Never paste a token into a chat or commit it to a repository.
29
+
30
+ ### 2. Add IssueWatcher to your AI app
31
+
32
+ Pick your app:
33
+
34
+ <details>
35
+ <summary><b>Claude Desktop (one click)</b></summary>
36
+
37
+ 1. Download `issuewatcher-<version>.mcpb` from the [latest release](https://github.com/mac146/IssueWatcher/releases).
38
+ 2. Open it with Claude Desktop (double-click it, or drag it into Claude Desktop's settings under Extensions).
39
+ 3. When asked, paste your GitHub token. It is stored in your computer's secure keychain.
40
+ 4. Turn the extension on.
41
+
42
+ </details>
43
+
44
+ <details>
45
+ <summary><b>Claude Code</b></summary>
46
+
47
+ ```bash
48
+ claude mcp add issuewatcher -e GITHUB_TOKEN=github_pat_xxxxxxxx -- npx -y issuewatcher-mcp
49
+ ```
50
+
51
+ Then run `/mcp` inside Claude Code to see it connected. The server is added for the current project folder; add `--scope user` to have it in every folder.
52
+
53
+ **Windows PowerShell** swallows the `--`, which makes Claude Code fail with `unknown option '-y'`. Put the `--` in quotes:
54
+
55
+ ```powershell
56
+ claude mcp add issuewatcher -e "GITHUB_TOKEN=github_pat_xxxxxxxx" '--' npx -y issuewatcher-mcp
57
+ ```
58
+
59
+ </details>
60
+
61
+ <details>
62
+ <summary><b>Cursor</b></summary>
63
+
64
+ Add this to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):
65
+
66
+ ```json
67
+ {
68
+ "mcpServers": {
69
+ "issuewatcher": {
70
+ "command": "npx",
71
+ "args": ["-y", "issuewatcher-mcp"],
72
+ "env": { "GITHUB_TOKEN": "github_pat_xxxxxxxx" }
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ </details>
79
+
80
+ <details>
81
+ <summary><b>VS Code (GitHub Copilot agent mode)</b></summary>
82
+
83
+ Add this to `.vscode/mcp.json`. VS Code will ask for the token when the server starts, so it is not stored in the file:
84
+
85
+ ```json
86
+ {
87
+ "inputs": [
88
+ { "type": "promptString", "id": "github_token", "description": "GitHub token", "password": true }
89
+ ],
90
+ "servers": {
91
+ "issuewatcher": {
92
+ "type": "stdio",
93
+ "command": "npx",
94
+ "args": ["-y", "issuewatcher-mcp"],
95
+ "env": { "GITHUB_TOKEN": "${input:github_token}" }
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ </details>
102
+
103
+ <details>
104
+ <summary><b>Any other MCP app (Windsurf, Zed, Cline, ...)</b></summary>
105
+
106
+ Use the same command in the app's MCP settings: command `npx`, arguments `-y issuewatcher-mcp`, environment variable `GITHUB_TOKEN`.
107
+
108
+ </details>
109
+
110
+ > **Windows:** if an app says it cannot start `npx`, use `"command": "cmd"` and `"args": ["/c", "npx", "-y", "issuewatcher-mcp"]`.
111
+
112
+ ### 3. Check it works (optional but recommended)
113
+
114
+ ```bash
115
+ GITHUB_TOKEN=github_pat_xxxxxxxx npx -y issuewatcher-mcp --check
116
+ ```
117
+
118
+ (On Windows PowerShell: `$env:GITHUB_TOKEN="github_pat_xxxxxxxx"; npx -y issuewatcher-mcp --check`.)
119
+
120
+ You should see:
121
+
122
+ ```
123
+ [ok] GitHub token found.
124
+ [ok] Token works: signed in as <you>.
125
+ [ok] API budget: 4994 of 5000 requests left (resets ...).
126
+ [ok] Watching 51 repos: 16 starred, 35 from libraries your projects use (7.2s).
127
+
128
+ All good. Add this server to your AI app, then ask: "What should I work on today?"
129
+ ```
130
+
131
+ ### 4. Ask
132
+
133
+ Restart your AI app, then try:
134
+
135
+ - *"What should I work on today?"*
136
+ - *"Show me good first issues from the last 24 hours."*
137
+ - *"What broke in my dependencies this week?"*
138
+ - *"Why did you rank the first one so high?"*
139
+ - *"Only show issues in nodejs/node and pydantic/pydantic."*
140
+ - *"What repos are you watching for me?"*
141
+
142
+ IssueWatcher starts loading your repositories as soon as your AI app starts it, so by the time you type your first question the answer is usually instant. If you ask within the first few seconds it may take up to about ten (longer on a slow connection); follow-up questions are instant for the next ten minutes. It looks at the 20 newest issues per repository in the time window.
143
+
144
+ ## What the assistant can do (the tools)
145
+
146
+ | Tool | What it does |
147
+ | --- | --- |
148
+ | `get_ranked_issues` | Ranks recent issues. Options: `mode` (`contributor`, `consumer`, `both`), `since` (`24h`, `3d`, `7d`, up to `30d`), `limit` (1-25), `min_score`, `source` (`starred`, `dependencies`, `both`), `repos`. |
149
+ | `explain_issue` | Shows every signal behind one issue's score, how it relates to your repos and dependencies, and (only if asked) a short excerpt of the issue text. |
150
+ | `list_watched_repos` | Shows which repos are watched and why, including which of your projects use which library. |
151
+
152
+ There is also a prompt, `issue_radar`, that some apps show as a shortcut.
153
+
154
+ ## How the ranking works
155
+
156
+ Every issue gets a score from 0 to 100 from transparent rules, each with a reason you can read:
157
+
158
+ - **Why it is on your radar:** a repo you starred, or a library one of your projects depends on (runtime counts more than dev tools).
159
+ - **Could you pick it up?** `good first issue` or `help wanted` labels, unassigned, opened recently, few comments, reproduction steps. Issues already assigned, claimed by a team, or where the reporter says they are opening a PR are scored down.
160
+ - **Could it break you?** the issue mentions a version in your range, or words like crash, regression, CVE.
161
+ - **Noise is scored down:** bots, questions, duplicates, vague or throwaway titles.
162
+
163
+ The rules and the reasoning behind them are written down in [DECISIONS.md](https://github.com/mac146/IssueWatcher/blob/main/DECISIONS.md). They are tuned by hand and still being validated, so use the reasons to judge each result yourself.
164
+
165
+ ## Settings
166
+
167
+ Set these as environment variables for the server (all optional except the token):
168
+
169
+ | Variable | Default | Meaning |
170
+ | --- | --- | --- |
171
+ | `GITHUB_TOKEN` | none | Required. A GitHub token (`GH_TOKEN` also works). |
172
+ | `SCORING_MODE` | `both` | `contributor`, `consumer` or `both`. The assistant can override it per question. |
173
+ | `DEPENDENCY_SCAN` | `true` | Set `false` to watch only starred repos. |
174
+ | `DEPENDENCY_REPOS` | your recently pushed repos | Comma-separated `owner/name` list of your projects to scan for dependencies. |
175
+ | `IGNORE_REPOS` | none | Repos to never show, with `*` wildcards, e.g. `bootcamp-*/*`. |
176
+ | `IGNORE_LABELS` | none | Labels to hide, e.g. `question,duplicate`. |
177
+ | `ISSUEWATCHER_CACHE_DIR` | `~/.issuewatcher` | Where the dependency list is cached (for 24 hours). |
178
+ | `ISSUEWATCHER_PREFETCH` | `true` | Load your repos and recent issues in the background when the server starts, so your first question is fast. Set `false` to load only when asked. |
179
+
180
+ ## Security and privacy
181
+
182
+ - **Read-only.** No tool can write to GitHub. Your token can be read-only too, and should be.
183
+ - **Your token stays on your computer** and is only sent to GitHub. It is never written to logs or included in any tool result.
184
+ - **No telemetry.** IssueWatcher sends nothing to its author. It contacts only `api.github.com`, and the npm and PyPI registries to find where your dependencies live.
185
+ - **What your AI app sees:** issue titles, labels, scores and links. Issue bodies are **not** included unless you ask for an excerpt.
186
+ - **Issue text is written by strangers.** A malicious issue could try to trick an AI into following instructions hidden in it ("prompt injection"). IssueWatcher strips hidden characters, link targets and markup, caps lengths, labels excerpts as untrusted, and tells the model never to obey them. This lowers the risk, it cannot remove it. If you also give your assistant tools that can write to GitHub, use a **separate read-only token** for IssueWatcher and keep write actions behind confirmation. See [D-033](https://github.com/mac146/IssueWatcher/blob/main/DECISIONS.md) for the full reasoning.
187
+
188
+ See also [PRIVACY.md](https://github.com/mac146/IssueWatcher/blob/main/docs/PRIVACY.md).
189
+
190
+ ## Do I need an LLM API key?
191
+
192
+ **No.** IssueWatcher itself never calls an AI model. Your assistant (Claude, Cursor's models, Copilot, ...) reads the ranked list with its own model and its own login. Nothing to configure here.
193
+
194
+ LLM API keys (Gemini, OpenAI, Claude, Groq, ...) are only used by the *separate* [email digest](https://github.com/mac146/IssueWatcher/blob/main/README.md) part of this project, which can send you a daily email of summaries.
195
+
196
+ ## Troubleshooting
197
+
198
+ | Symptom | What to try |
199
+ | --- | --- |
200
+ | App shows "server disconnected" or no tools | Run the `--check` command from step 3 in a terminal; it prints the exact problem. Make sure Node.js 20 or newer is installed (`node --version`). |
201
+ | "needs a GitHub token" | The token is missing or not passed to the server. Re-check the `env` / `-e` setting and restart the app. |
202
+ | "GitHub rejected the token (401)" | The token expired or was copied wrongly. Create a new one. |
203
+ | "No repositories to watch" | You have not starred any repositories yet. Star a few and try again. |
204
+ | No dependencies found | Dependency matching reads `package.json` / `requirements.txt` in your recently pushed repos. For private repos the token needs **Contents: Read-only**. Or list projects with `DEPENDENCY_REPOS`. |
205
+ | First answer is slow, or says "still loading" | The first load of ~50 repos takes up to ten seconds, longer on a slow connection. Ask again in a few seconds: the work continues in the background. If a scan runs out of its 20-second limit the answer says so and lists how many repos it did not reach. |
206
+ | Results look wrong | Ask "explain the first one" and read the signals; then [tell us](https://github.com/mac146/IssueWatcher/issues). That feedback is how the ranking improves. |
207
+
208
+ ## Run it another way
209
+
210
+ **From source:**
211
+
212
+ ```bash
213
+ git clone https://github.com/mac146/IssueWatcher && cd IssueWatcher/mcp-server
214
+ npm ci && npm run build
215
+ GITHUB_TOKEN=github_pat_xxxxxxxx node build/mcp/main.js --check
216
+ ```
217
+
218
+ Use `node /full/path/to/IssueWatcher/mcp-server/build/mcp/main.js` as the command in your app's config.
219
+
220
+ **With Docker** (the server speaks MCP over stdio, so keep `-i`):
221
+
222
+ ```bash
223
+ docker build --target mcp -t issuewatcher-mcp mcp-server
224
+ docker run -i --rm -e GITHUB_TOKEN=github_pat_xxxxxxxx issuewatcher-mcp
225
+ ```
226
+
227
+ ## Uninstall
228
+
229
+ Remove it from your AI app's settings, then delete the cache folder (`~/.issuewatcher`) and revoke the token at <https://github.com/settings/personal-access-tokens>.
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { logger } from "../logger.js";
4
+ import { MANIFEST_FILES, parseManifest } from "./manifests.js";
5
+ const DAY_MS = 86_400_000;
6
+ async function inPool(items, size, fn) {
7
+ const out = new Array(items.length);
8
+ let next = 0;
9
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
10
+ while (next < items.length) {
11
+ const i = next++;
12
+ out[i] = await fn(items[i]);
13
+ }
14
+ }));
15
+ return out;
16
+ }
17
+ /**
18
+ * Collects the dependencies declared across the user's repos, keeps the most
19
+ * widely used ones, and maps each to the GitHub repo that hosts its source.
20
+ * Packages whose repo can't be resolved are skipped (and logged at debug level).
21
+ */
22
+ export async function discoverDependencies(source, registry, opts, now = Date.now) {
23
+ let repos;
24
+ if (opts.repos.length) {
25
+ repos = opts.repos.map((r) => {
26
+ const [owner, name] = r.split("/");
27
+ return { owner: owner, name: name };
28
+ });
29
+ }
30
+ else {
31
+ const cutoff = now() - opts.activeWithinDays * DAY_MS;
32
+ repos = (await source.listOwnRepos())
33
+ .filter((r) => !r.pushedAt || Date.parse(r.pushedAt) >= cutoff)
34
+ .slice(0, opts.maxRepos);
35
+ }
36
+ const aggregated = new Map();
37
+ await inPool(repos, 4, async ({ owner, name }) => {
38
+ for (const file of MANIFEST_FILES) {
39
+ let text;
40
+ try {
41
+ text = await source.getFile(owner, name, file);
42
+ }
43
+ catch (err) {
44
+ logger.warn(`Could not read ${owner}/${name}/${file}`, err);
45
+ continue;
46
+ }
47
+ if (!text)
48
+ continue;
49
+ for (const dep of parseManifest(file, text)) {
50
+ const key = `${dep.ecosystem}:${dep.pkg}`;
51
+ const existing = aggregated.get(key);
52
+ if (existing) {
53
+ existing.usedBy.add(`${owner}/${name}`);
54
+ if (dep.kind === "runtime")
55
+ existing.kind = "runtime";
56
+ }
57
+ else {
58
+ aggregated.set(key, { ...dep, usedBy: new Set([`${owner}/${name}`]) });
59
+ }
60
+ }
61
+ }
62
+ });
63
+ // Most widely used first, runtime before dev, then alphabetical for stable output.
64
+ const ranked = [...aggregated.values()]
65
+ .sort((a, b) => b.usedBy.size - a.usedBy.size ||
66
+ Number(b.kind === "runtime") - Number(a.kind === "runtime") ||
67
+ a.pkg.localeCompare(b.pkg))
68
+ .slice(0, opts.maxDependencies);
69
+ const resolved = await inPool(ranked, 5, async (dep) => {
70
+ try {
71
+ return await registry.resolve(dep.ecosystem, dep.pkg);
72
+ }
73
+ catch (err) {
74
+ logger.warn(`Registry lookup failed for ${dep.ecosystem}:${dep.pkg}`, err);
75
+ return null;
76
+ }
77
+ });
78
+ const map = {};
79
+ ranked.forEach((dep, i) => {
80
+ const repo = resolved[i];
81
+ if (!repo)
82
+ return;
83
+ (map[repo] ??= []).push({ ...dep, usedBy: [...dep.usedBy].sort() });
84
+ });
85
+ logger.info(`Dependency scan: ${repos.length} repo(s), ${aggregated.size} package(s), ${Object.keys(map).length} source repo(s) resolved`);
86
+ return map;
87
+ }
88
+ /**
89
+ * Wraps discovery with a disk cache so manifests and registries are not hit on
90
+ * every poll. If a refresh fails, the stale cache is used rather than losing
91
+ * the dependency watch list.
92
+ */
93
+ export class CachedDiscovery {
94
+ run;
95
+ file;
96
+ ttlMs;
97
+ now;
98
+ constructor(run, file, ttlMs, now = Date.now) {
99
+ this.run = run;
100
+ this.file = file;
101
+ this.ttlMs = ttlMs;
102
+ this.now = now;
103
+ }
104
+ async get() {
105
+ const cached = await this.read();
106
+ if (cached && this.now() - cached.fetchedAt < this.ttlMs)
107
+ return cached.map;
108
+ try {
109
+ const map = await this.run();
110
+ await this.write({ fetchedAt: this.now(), map });
111
+ return map;
112
+ }
113
+ catch (err) {
114
+ logger.warn("Dependency discovery failed; using cached list if any", err);
115
+ return cached?.map ?? {};
116
+ }
117
+ }
118
+ async read() {
119
+ try {
120
+ const parsed = JSON.parse(await fs.readFile(this.file, "utf-8"));
121
+ return typeof parsed.fetchedAt === "number" && parsed.map ? parsed : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ async write(data) {
128
+ await fs.mkdir(path.dirname(this.file), { recursive: true });
129
+ const tmp = `${this.file}.${process.pid}.tmp`;
130
+ await fs.writeFile(tmp, JSON.stringify(data), "utf-8");
131
+ await fs.rename(tmp, this.file);
132
+ }
133
+ }
@@ -0,0 +1,57 @@
1
+ /** Files looked for at the root of each of the user's repos. */
2
+ export const MANIFEST_FILES = ["package.json", "requirements.txt"];
3
+ /** Specs that don't point at a registry package. */
4
+ const NON_REGISTRY = /^(?:file:|link:|workspace:|git\+|git:|github:|https?:|npm:|\.{0,2}\/)/;
5
+ export function parsePackageJson(text) {
6
+ let json;
7
+ try {
8
+ json = JSON.parse(text);
9
+ }
10
+ catch {
11
+ return [];
12
+ }
13
+ if (typeof json !== "object" || json === null)
14
+ return [];
15
+ const manifest = json;
16
+ const out = [];
17
+ const add = (section, kind) => {
18
+ if (typeof section !== "object" || section === null)
19
+ return;
20
+ for (const [pkg, range] of Object.entries(section)) {
21
+ if (typeof range !== "string" || NON_REGISTRY.test(range))
22
+ continue;
23
+ out.push({ ecosystem: "npm", pkg, range, kind });
24
+ }
25
+ };
26
+ add(manifest.dependencies, "runtime");
27
+ add(manifest.devDependencies, "dev");
28
+ return out;
29
+ }
30
+ /** Handles `name==1.2`, `name>=1.2,<2`, extras (`name[extra]`) and markers (`; python_version...`). */
31
+ export function parseRequirementsTxt(text) {
32
+ const out = [];
33
+ for (const raw of text.split(/\r?\n/)) {
34
+ const line = raw.split("#")[0].split(";")[0].trim();
35
+ if (!line || line.startsWith("-") || /^(?:git\+|https?:|\.{0,2}\/)/.test(line))
36
+ continue;
37
+ const m = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*(.*)$/);
38
+ if (!m)
39
+ continue;
40
+ out.push({ ecosystem: "pypi", pkg: m[1].toLowerCase().replace(/[_.]+/g, "-"), range: m[2].trim() || "*", kind: "runtime" });
41
+ }
42
+ return out;
43
+ }
44
+ export function parseManifest(file, text) {
45
+ if (file === "package.json")
46
+ return parsePackageJson(text);
47
+ if (file === "requirements.txt")
48
+ return parseRequirementsTxt(text);
49
+ return [];
50
+ }
51
+ /** Lowest version a range allows, e.g. "^1.6.0" -> 1.6, ">=2.1,<3" -> 2.1, "==4.0.1" -> 4.0. */
52
+ export function minVersion(range) {
53
+ const m = range.match(/(\d+)(?:\.(\d+|x|\*))?/);
54
+ if (!m)
55
+ return null;
56
+ return { major: Number(m[1]), minor: m[2] && /^\d+$/.test(m[2]) ? Number(m[2]) : 0 };
57
+ }
@@ -0,0 +1,60 @@
1
+ import axios from "axios";
2
+ /**
3
+ * Extracts "owner/name" from the many spellings of a GitHub URL found in
4
+ * package metadata: git+https://…/o/r.git, github:o/r, git@github.com:o/r.git,
5
+ * https://github.com/o/r/tree/main/packages/x, and bare "o/r" shorthand.
6
+ */
7
+ export function githubRepoFromUrl(url) {
8
+ if (!url)
9
+ return null;
10
+ const s = url.trim();
11
+ const m = s.match(/github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:[/#?].*)?$/i) ??
12
+ s.match(/^github:([\w.-]+)\/([\w.-]+?)(?:\.git)?$/i) ??
13
+ s.match(/^([\w.-]+)\/([\w.-]+)$/);
14
+ return m ? `${m[1]}/${m[2]}` : null;
15
+ }
16
+ export class HttpRegistry {
17
+ client;
18
+ urls;
19
+ constructor(client = axios.create({
20
+ timeout: 15_000,
21
+ headers: { "User-Agent": "IssueWatcher" },
22
+ }),
23
+ /** Overridden in tests (a local stub) and for registry mirrors. */
24
+ urls = { npm: "https://registry.npmjs.org", pypi: "https://pypi.org" }) {
25
+ this.client = client;
26
+ this.urls = urls;
27
+ }
28
+ async resolve(ecosystem, pkg) {
29
+ try {
30
+ return ecosystem === "npm" ? await this.npm(pkg) : await this.pypi(pkg);
31
+ }
32
+ catch (err) {
33
+ // An unknown package is a normal "no repo"; anything else should surface.
34
+ if (axios.isAxiosError(err) && err.response?.status === 404)
35
+ return null;
36
+ throw err;
37
+ }
38
+ }
39
+ async npm(pkg) {
40
+ const res = await this.client.get(`${this.urls.npm}/${pkg.replace("/", "%2F")}/latest`);
41
+ const repo = res.data.repository;
42
+ return githubRepoFromUrl(typeof repo === "string" ? repo : repo?.url);
43
+ }
44
+ async pypi(pkg) {
45
+ const res = await this.client.get(`${this.urls.pypi}/pypi/${pkg}/json`);
46
+ const urls = res.data.info?.project_urls ?? {};
47
+ const preferred = ["Source", "Source Code", "Repository", "Code", "Homepage", "GitHub"];
48
+ const candidates = [
49
+ ...preferred.map((k) => urls[k]),
50
+ ...Object.values(urls),
51
+ res.data.info?.home_page,
52
+ ];
53
+ for (const url of candidates) {
54
+ const repo = githubRepoFromUrl(url);
55
+ if (repo)
56
+ return repo;
57
+ }
58
+ return null;
59
+ }
60
+ }