@theokit/sdk-tools 0.27.5 → 0.28.0

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.
@@ -0,0 +1,227 @@
1
+ 'use strict';
2
+
3
+ var sdk = require('@theokit/sdk');
4
+ var zod = require('zod');
5
+
6
+ // src/github-link-preview.ts
7
+ var API_BASE = "https://api.github.com";
8
+ var DEFAULT_TIMEOUT_MS = 1e4;
9
+ var DEFAULT_MAX_SUMMARY_CHARS = 500;
10
+ var MAX_BODY_BYTES = 1048576;
11
+ var GITHUB_HOSTS = /* @__PURE__ */ new Set(["github.com", "www.github.com"]);
12
+ var OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
13
+ var REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
14
+ var NUMBER_PATTERN = /^[1-9][0-9]{0,9}$/;
15
+ var PreviewFailure = class extends Error {
16
+ constructor(error, extra = {}) {
17
+ super(error);
18
+ this.error = error;
19
+ this.extra = extra;
20
+ }
21
+ error;
22
+ extra;
23
+ };
24
+ function requirePositiveInteger(name, value) {
25
+ if (!Number.isInteger(value) || value <= 0) {
26
+ throw new RangeError(
27
+ `createGithubLinkPreviewTool: ${name} must be a positive integer, got ${value}`
28
+ );
29
+ }
30
+ return value;
31
+ }
32
+ function hasControlCharacter(text) {
33
+ return Array.from(text).some((char) => {
34
+ const code = char.charCodeAt(0);
35
+ return code < 32 || code === 127;
36
+ });
37
+ }
38
+ function requireValidToken(token) {
39
+ if (token.trim() === "" || hasControlCharacter(token)) {
40
+ throw new RangeError(
41
+ "createGithubLinkPreviewTool: token must be non-blank and free of control characters"
42
+ );
43
+ }
44
+ return token;
45
+ }
46
+ function buildHeaders(token) {
47
+ const headers = {
48
+ accept: "application/vnd.github+json",
49
+ "user-agent": "theokit-sdk-tools",
50
+ "x-github-api-version": "2022-11-28"
51
+ };
52
+ if (token !== void 0) headers.authorization = `Bearer ${token}`;
53
+ return headers;
54
+ }
55
+ function isGithubOrigin(parsed) {
56
+ return parsed.protocol === "https:" && GITHUB_HOSTS.has(parsed.hostname) && parsed.username === "" && parsed.password === "" && parsed.port === "";
57
+ }
58
+ var SECTION_KINDS = {
59
+ issues: "issue",
60
+ pull: "pull_request"
61
+ };
62
+ function isValidRepository(owner, repo) {
63
+ return OWNER_PATTERN.test(owner) && REPO_PATTERN.test(repo) && repo !== "." && repo !== "..";
64
+ }
65
+ function numberedTarget(owner, repo, segments) {
66
+ const [, , section = "", number = ""] = segments;
67
+ const kind = Object.hasOwn(SECTION_KINDS, section) ? SECTION_KINDS[section] : void 0;
68
+ if (segments.length !== 4 || kind === void 0 || !NUMBER_PATTERN.test(number)) return void 0;
69
+ return { kind, owner, repo, number };
70
+ }
71
+ function targetFromSegments(segments) {
72
+ const [owner = "", repo = ""] = segments;
73
+ if (!isValidRepository(owner, repo)) return void 0;
74
+ if (segments.length === 2) return { kind: "repository", owner, repo };
75
+ return numberedTarget(owner, repo, segments);
76
+ }
77
+ function parseGithubUrl(url) {
78
+ let parsed;
79
+ try {
80
+ parsed = new URL(url);
81
+ } catch {
82
+ return void 0;
83
+ }
84
+ if (!isGithubOrigin(parsed)) return void 0;
85
+ const segments = parsed.pathname.split("/").slice(1);
86
+ if (segments.at(-1) === "") segments.pop();
87
+ return targetFromSegments(segments);
88
+ }
89
+ function apiUrlFor(target) {
90
+ const base = `${API_BASE}/repos/${target.owner}/${target.repo}`;
91
+ if (target.kind === "issue") return `${base}/issues/${target.number}`;
92
+ if (target.kind === "pull_request") return `${base}/pulls/${target.number}`;
93
+ return base;
94
+ }
95
+ function canonicalUrlFor(target) {
96
+ const base = `https://github.com/${target.owner}/${target.repo}`;
97
+ if (target.kind === "issue") return `${base}/issues/${target.number}`;
98
+ if (target.kind === "pull_request") return `${base}/pull/${target.number}`;
99
+ return base;
100
+ }
101
+ function retryAfterSeconds(headers, nowMs) {
102
+ const retryAfter = Number(headers.get("retry-after") ?? Number.NaN);
103
+ if (Number.isFinite(retryAfter)) return Math.max(0, Math.ceil(retryAfter));
104
+ const reset = Number(headers.get("x-ratelimit-reset") ?? Number.NaN);
105
+ if (Number.isFinite(reset)) return Math.max(0, Math.ceil(reset - nowMs / 1e3));
106
+ return void 0;
107
+ }
108
+ function isRateLimited(response) {
109
+ if (response.status !== 403 && response.status !== 429) return false;
110
+ return response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after");
111
+ }
112
+ function checkStatus(response, nowMs) {
113
+ const { status } = response;
114
+ if (status >= 200 && status < 300) return;
115
+ if (status >= 300 && status < 400) throw new PreviewFailure("redirect_blocked", { status });
116
+ if (status === 404) throw new PreviewFailure("not_found", { status });
117
+ if (isRateLimited(response)) {
118
+ const retry = retryAfterSeconds(response.headers, nowMs);
119
+ throw new PreviewFailure("rate_limited", {
120
+ status,
121
+ ...retry === void 0 ? {} : { retry_after_seconds: retry }
122
+ });
123
+ }
124
+ throw new PreviewFailure("upstream_error", { status });
125
+ }
126
+ async function readCappedJson(response) {
127
+ if (Number(response.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) {
128
+ throw new PreviewFailure("invalid_response");
129
+ }
130
+ const text = await response.text();
131
+ if (Buffer.byteLength(text, "utf8") > MAX_BODY_BYTES) {
132
+ throw new PreviewFailure("invalid_response");
133
+ }
134
+ try {
135
+ return JSON.parse(text);
136
+ } catch {
137
+ throw new PreviewFailure("invalid_response");
138
+ }
139
+ }
140
+ function cutCodePoints(text, max) {
141
+ if (typeof text !== "string") return "";
142
+ return Array.from(text).slice(0, max).join("");
143
+ }
144
+ function stateOf(target, body) {
145
+ if (target.kind === "repository") return body.archived === true ? "archived" : "active";
146
+ if (target.kind === "pull_request" && body.merged === true) return "merged";
147
+ if (typeof body.state !== "string") throw new PreviewFailure("invalid_response");
148
+ return body.state;
149
+ }
150
+ function mapPreview(target, body, maxSummaryChars) {
151
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
152
+ throw new PreviewFailure("invalid_response");
153
+ }
154
+ const record = body;
155
+ const isRepository = target.kind === "repository";
156
+ const title = isRepository ? record.full_name : record.title;
157
+ if (typeof title !== "string") throw new PreviewFailure("invalid_response");
158
+ return {
159
+ ok: true,
160
+ kind: target.kind,
161
+ url: canonicalUrlFor(target),
162
+ title,
163
+ state: stateOf(target, record),
164
+ summary: cutCodePoints(isRepository ? record.description : record.body, maxSummaryChars)
165
+ };
166
+ }
167
+ function failureFor(err, timeoutMs) {
168
+ if (err instanceof PreviewFailure) return err;
169
+ const name = err?.name;
170
+ if (name === "AbortError" || name === "TimeoutError") {
171
+ return new PreviewFailure("timeout", { timeout_ms: timeoutMs });
172
+ }
173
+ return new PreviewFailure("fetch_failed", {
174
+ error_name: typeof name === "string" ? name : "unknown"
175
+ });
176
+ }
177
+ async function fetchPreview(target, settings) {
178
+ const controller = new AbortController();
179
+ const timer = setTimeout(() => controller.abort(), settings.timeoutMs);
180
+ try {
181
+ const response = await settings.fetchImpl(apiUrlFor(target), {
182
+ method: "GET",
183
+ redirect: "manual",
184
+ signal: controller.signal,
185
+ headers: settings.headers
186
+ });
187
+ checkStatus(response, settings.now());
188
+ const body = await readCappedJson(response);
189
+ return mapPreview(target, body, settings.maxSummaryChars);
190
+ } finally {
191
+ clearTimeout(timer);
192
+ }
193
+ }
194
+ async function preview(url, settings) {
195
+ const target = parseGithubUrl(url);
196
+ if (target === void 0) return { ok: false, error: "unsupported_url", url };
197
+ try {
198
+ return await fetchPreview(target, settings);
199
+ } catch (err) {
200
+ const failure = failureFor(err, settings.timeoutMs);
201
+ return { ok: false, error: failure.error, url, ...failure.extra };
202
+ }
203
+ }
204
+ function createGithubLinkPreviewTool(opts) {
205
+ const settings = {
206
+ fetchImpl: opts?.fetchImpl ?? fetch,
207
+ headers: buildHeaders(opts?.token === void 0 ? void 0 : requireValidToken(opts.token)),
208
+ timeoutMs: requirePositiveInteger("timeoutMs", opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS),
209
+ maxSummaryChars: requirePositiveInteger(
210
+ "maxSummaryChars",
211
+ opts?.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS
212
+ ),
213
+ now: opts?.now ?? Date.now
214
+ };
215
+ return sdk.Tool.create({
216
+ name: opts?.name ?? "github_link_preview",
217
+ description: opts?.description ?? "Preview a GitHub issue, pull request or repository from its github.com URL. Returns { ok, kind, url, title, state, summary } where state is open/closed/merged for issues and pull requests and active/archived for repositories, or { ok: false, error }. Only https://github.com/<owner>/<repo>, .../issues/<n> and .../pull/<n> are accepted.",
218
+ inputSchema: zod.z.object({
219
+ url: zod.z.string().min(1).describe("A github.com repository, issue or pull request URL.")
220
+ }),
221
+ handler: async ({ url }) => JSON.stringify(await preview(url, settings))
222
+ });
223
+ }
224
+
225
+ exports.createGithubLinkPreviewTool = createGithubLinkPreviewTool;
226
+ //# sourceMappingURL=github-link-preview.cjs.map
227
+ //# sourceMappingURL=github-link-preview.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/github-link-preview.ts"],"names":["Tool","z"],"mappings":";;;;;;AAoBA,IAAM,QAAA,GAAW,wBAAA;AACjB,IAAM,kBAAA,GAAqB,GAAA;AAC3B,IAAM,yBAAA,GAA4B,GAAA;AAClC,IAAM,cAAA,GAAiB,OAAA;AACvB,IAAM,+BAAe,IAAI,GAAA,CAAI,CAAC,YAAA,EAAc,gBAAgB,CAAC,CAAA;AAC7D,IAAM,aAAA,GAAgB,qCAAA;AACtB,IAAM,YAAA,GAAe,yBAAA;AACrB,IAAM,cAAA,GAAiB,mBAAA;AAqEvB,IAAM,cAAA,GAAN,cAA6B,KAAA,CAAM;AAAA,EACjC,WAAA,CACW,KAAA,EACA,KAAA,GAA+C,EAAC,EACzD;AACA,IAAA,KAAA,CAAM,KAAK,CAAA;AAHF,IAAA,IAAA,CAAA,KAAA,GAAA,KAAA;AACA,IAAA,IAAA,CAAA,KAAA,GAAA,KAAA;AAAA,EAGX;AAAA,EAJW,KAAA;AAAA,EACA,KAAA;AAIb,CAAA;AAEA,SAAS,sBAAA,CAAuB,MAAc,KAAA,EAAuB;AACnE,EAAA,IAAI,CAAC,MAAA,CAAO,SAAA,CAAU,KAAK,CAAA,IAAK,SAAS,CAAA,EAAG;AAC1C,IAAA,MAAM,IAAI,UAAA;AAAA,MACR,CAAA,6BAAA,EAAgC,IAAI,CAAA,iCAAA,EAAoC,KAAK,CAAA;AAAA,KAC/E;AAAA,EACF;AACA,EAAA,OAAO,KAAA;AACT;AAEA,SAAS,oBAAoB,IAAA,EAAuB;AAClD,EAAA,OAAO,MAAM,IAAA,CAAK,IAAI,CAAA,CAAE,IAAA,CAAK,CAAC,IAAA,KAAS;AACrC,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,UAAA,CAAW,CAAC,CAAA;AAC9B,IAAA,OAAO,IAAA,GAAO,MAAQ,IAAA,KAAS,GAAA;AAAA,EACjC,CAAC,CAAA;AACH;AAEA,SAAS,kBAAkB,KAAA,EAAuB;AAChD,EAAA,IAAI,MAAM,IAAA,EAAK,KAAM,EAAA,IAAM,mBAAA,CAAoB,KAAK,CAAA,EAAG;AACrD,IAAA,MAAM,IAAI,UAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AACA,EAAA,OAAO,KAAA;AACT;AAEA,SAAS,aAAa,KAAA,EAAmD;AACvE,EAAA,MAAM,OAAA,GAAkC;AAAA,IACtC,MAAA,EAAQ,6BAAA;AAAA,IACR,YAAA,EAAc,mBAAA;AAAA,IACd,sBAAA,EAAwB;AAAA,GAC1B;AACA,EAAA,IAAI,KAAA,KAAU,MAAA,EAAW,OAAA,CAAQ,aAAA,GAAgB,UAAU,KAAK,CAAA,CAAA;AAChE,EAAA,OAAO,OAAA;AACT;AAEA,SAAS,eAAe,MAAA,EAAsB;AAC5C,EAAA,OACE,MAAA,CAAO,QAAA,KAAa,QAAA,IACpB,YAAA,CAAa,IAAI,MAAA,CAAO,QAAQ,CAAA,IAChC,MAAA,CAAO,aAAa,EAAA,IACpB,MAAA,CAAO,QAAA,KAAa,EAAA,IACpB,OAAO,IAAA,KAAS,EAAA;AAEpB;AAEA,IAAM,aAAA,GAA0D;AAAA,EAC9D,MAAA,EAAQ,OAAA;AAAA,EACR,IAAA,EAAM;AACR,CAAA;AAEA,SAAS,iBAAA,CAAkB,OAAe,IAAA,EAAuB;AAC/D,EAAA,OAAO,aAAA,CAAc,IAAA,CAAK,KAAK,CAAA,IAAK,YAAA,CAAa,KAAK,IAAI,CAAA,IAAK,IAAA,KAAS,GAAA,IAAO,IAAA,KAAS,IAAA;AAC1F;AAEA,SAAS,cAAA,CAAe,KAAA,EAAe,IAAA,EAAc,QAAA,EAA8C;AACjG,EAAA,MAAM,KAAK,UAAU,EAAA,EAAI,MAAA,GAAS,EAAE,CAAA,GAAI,QAAA;AACxC,EAAA,MAAM,IAAA,GAAO,OAAO,MAAA,CAAO,aAAA,EAAe,OAAO,CAAA,GAAI,aAAA,CAAc,OAAO,CAAA,GAAI,MAAA;AAC9E,EAAA,IAAI,QAAA,CAAS,MAAA,KAAW,CAAA,IAAK,IAAA,KAAS,MAAA,IAAa,CAAC,cAAA,CAAe,IAAA,CAAK,MAAM,CAAA,EAAG,OAAO,MAAA;AACxF,EAAA,OAAO,EAAE,IAAA,EAAM,KAAA,EAAO,IAAA,EAAM,MAAA,EAAO;AACrC;AAEA,SAAS,mBAAmB,QAAA,EAA8C;AACxE,EAAA,MAAM,CAAC,KAAA,GAAQ,EAAA,EAAI,IAAA,GAAO,EAAE,CAAA,GAAI,QAAA;AAChC,EAAA,IAAI,CAAC,iBAAA,CAAkB,KAAA,EAAO,IAAI,GAAG,OAAO,MAAA;AAC5C,EAAA,IAAI,QAAA,CAAS,WAAW,CAAA,EAAG,OAAO,EAAE,IAAA,EAAM,YAAA,EAAc,OAAO,IAAA,EAAK;AACpE,EAAA,OAAO,cAAA,CAAe,KAAA,EAAO,IAAA,EAAM,QAAQ,CAAA;AAC7C;AAEA,SAAS,eAAe,GAAA,EAAuC;AAC7D,EAAA,IAAI,MAAA;AACJ,EAAA,IAAI;AACF,IAAA,MAAA,GAAS,IAAI,IAAI,GAAG,CAAA;AAAA,EACtB,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,IAAI,CAAC,cAAA,CAAe,MAAM,CAAA,EAAG,OAAO,MAAA;AACpC,EAAA,MAAM,WAAW,MAAA,CAAO,QAAA,CAAS,MAAM,GAAG,CAAA,CAAE,MAAM,CAAC,CAAA;AACnD,EAAA,IAAI,SAAS,EAAA,CAAG,EAAE,CAAA,KAAM,EAAA,WAAa,GAAA,EAAI;AACzC,EAAA,OAAO,mBAAmB,QAAQ,CAAA;AACpC;AAEA,SAAS,UAAU,MAAA,EAA8B;AAC/C,EAAA,MAAM,IAAA,GAAO,GAAG,QAAQ,CAAA,OAAA,EAAU,OAAO,KAAK,CAAA,CAAA,EAAI,OAAO,IAAI,CAAA,CAAA;AAC7D,EAAA,IAAI,MAAA,CAAO,SAAS,OAAA,EAAS,OAAO,GAAG,IAAI,CAAA,QAAA,EAAW,OAAO,MAAM,CAAA,CAAA;AACnE,EAAA,IAAI,MAAA,CAAO,SAAS,cAAA,EAAgB,OAAO,GAAG,IAAI,CAAA,OAAA,EAAU,OAAO,MAAM,CAAA,CAAA;AACzE,EAAA,OAAO,IAAA;AACT;AAEA,SAAS,gBAAgB,MAAA,EAA8B;AACrD,EAAA,MAAM,OAAO,CAAA,mBAAA,EAAsB,MAAA,CAAO,KAAK,CAAA,CAAA,EAAI,OAAO,IAAI,CAAA,CAAA;AAC9D,EAAA,IAAI,MAAA,CAAO,SAAS,OAAA,EAAS,OAAO,GAAG,IAAI,CAAA,QAAA,EAAW,OAAO,MAAM,CAAA,CAAA;AACnE,EAAA,IAAI,MAAA,CAAO,SAAS,cAAA,EAAgB,OAAO,GAAG,IAAI,CAAA,MAAA,EAAS,OAAO,MAAM,CAAA,CAAA;AACxE,EAAA,OAAO,IAAA;AACT;AAGA,SAAS,iBAAA,CAAkB,SAAkB,KAAA,EAAmC;AAC9E,EAAA,MAAM,aAAa,MAAA,CAAO,OAAA,CAAQ,IAAI,aAAa,CAAA,IAAK,OAAO,GAAG,CAAA;AAClE,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,UAAU,CAAA,EAAG,OAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,IAAA,CAAK,UAAU,CAAC,CAAA;AACzE,EAAA,MAAM,QAAQ,MAAA,CAAO,OAAA,CAAQ,IAAI,mBAAmB,CAAA,IAAK,OAAO,GAAG,CAAA;AACnE,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,KAAK,CAAA,EAAG,OAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,IAAA,CAAK,KAAA,GAAQ,KAAA,GAAQ,GAAI,CAAC,CAAA;AAC9E,EAAA,OAAO,MAAA;AACT;AAEA,SAAS,cAAc,QAAA,EAA6B;AAClD,EAAA,IAAI,SAAS,MAAA,KAAW,GAAA,IAAO,QAAA,CAAS,MAAA,KAAW,KAAK,OAAO,KAAA;AAC/D,EAAA,OACE,QAAA,CAAS,QAAQ,GAAA,CAAI,uBAAuB,MAAM,GAAA,IAAO,QAAA,CAAS,OAAA,CAAQ,GAAA,CAAI,aAAa,CAAA;AAE/F;AAEA,SAAS,WAAA,CAAY,UAAoB,KAAA,EAAqB;AAC5D,EAAA,MAAM,EAAE,QAAO,GAAI,QAAA;AACnB,EAAA,IAAI,MAAA,IAAU,GAAA,IAAO,MAAA,GAAS,GAAA,EAAK;AACnC,EAAA,IAAI,MAAA,IAAU,GAAA,IAAO,MAAA,GAAS,GAAA,EAAK,MAAM,IAAI,cAAA,CAAe,kBAAA,EAAoB,EAAE,MAAA,EAAQ,CAAA;AAC1F,EAAA,IAAI,MAAA,KAAW,KAAK,MAAM,IAAI,eAAe,WAAA,EAAa,EAAE,QAAQ,CAAA;AACpE,EAAA,IAAI,aAAA,CAAc,QAAQ,CAAA,EAAG;AAC3B,IAAA,MAAM,KAAA,GAAQ,iBAAA,CAAkB,QAAA,CAAS,OAAA,EAAS,KAAK,CAAA;AACvD,IAAA,MAAM,IAAI,eAAe,cAAA,EAAgB;AAAA,MACvC,MAAA;AAAA,MACA,GAAI,KAAA,KAAU,MAAA,GAAY,EAAC,GAAI,EAAE,qBAAqB,KAAA;AAAM,KAC7D,CAAA;AAAA,EACH;AACA,EAAA,MAAM,IAAI,cAAA,CAAe,gBAAA,EAAkB,EAAE,QAAQ,CAAA;AACvD;AAEA,eAAe,eAAe,QAAA,EAAsC;AAClE,EAAA,IAAI,MAAA,CAAO,SAAS,OAAA,CAAQ,GAAA,CAAI,gBAAgB,CAAA,IAAK,CAAC,IAAI,cAAA,EAAgB;AACxE,IAAA,MAAM,IAAI,eAAe,kBAAkB,CAAA;AAAA,EAC7C;AACA,EAAA,MAAM,IAAA,GAAO,MAAM,QAAA,CAAS,IAAA,EAAK;AACjC,EAAA,IAAI,MAAA,CAAO,UAAA,CAAW,IAAA,EAAM,MAAM,IAAI,cAAA,EAAgB;AACpD,IAAA,MAAM,IAAI,eAAe,kBAAkB,CAAA;AAAA,EAC7C;AACA,EAAA,IAAI;AACF,IAAA,OAAO,IAAA,CAAK,MAAM,IAAI,CAAA;AAAA,EACxB,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,eAAe,kBAAkB,CAAA;AAAA,EAC7C;AACF;AAEA,SAAS,aAAA,CAAc,MAAe,GAAA,EAAqB;AACzD,EAAA,IAAI,OAAO,IAAA,KAAS,QAAA,EAAU,OAAO,EAAA;AACrC,EAAA,OAAO,KAAA,CAAM,KAAK,IAAI,CAAA,CAAE,MAAM,CAAA,EAAG,GAAG,CAAA,CAAE,IAAA,CAAK,EAAE,CAAA;AAC/C;AAEA,SAAS,OAAA,CAAQ,QAAsB,IAAA,EAAuC;AAC5E,EAAA,IAAI,OAAO,IAAA,KAAS,YAAA,SAAqB,IAAA,CAAK,QAAA,KAAa,OAAO,UAAA,GAAa,QAAA;AAC/E,EAAA,IAAI,OAAO,IAAA,KAAS,cAAA,IAAkB,IAAA,CAAK,MAAA,KAAW,MAAM,OAAO,QAAA;AACnE,EAAA,IAAI,OAAO,IAAA,CAAK,KAAA,KAAU,UAAU,MAAM,IAAI,eAAe,kBAAkB,CAAA;AAC/E,EAAA,OAAO,IAAA,CAAK,KAAA;AACd;AAEA,SAAS,UAAA,CACP,MAAA,EACA,IAAA,EACA,eAAA,EACyB;AACzB,EAAA,IAAI,OAAO,SAAS,QAAA,IAAY,IAAA,KAAS,QAAQ,KAAA,CAAM,OAAA,CAAQ,IAAI,CAAA,EAAG;AACpE,IAAA,MAAM,IAAI,eAAe,kBAAkB,CAAA;AAAA,EAC7C;AACA,EAAA,MAAM,MAAA,GAAS,IAAA;AACf,EAAA,MAAM,YAAA,GAAe,OAAO,IAAA,KAAS,YAAA;AACrC,EAAA,MAAM,KAAA,GAAQ,YAAA,GAAe,MAAA,CAAO,SAAA,GAAY,MAAA,CAAO,KAAA;AACvD,EAAA,IAAI,OAAO,KAAA,KAAU,QAAA,EAAU,MAAM,IAAI,eAAe,kBAAkB,CAAA;AAC1E,EAAA,OAAO;AAAA,IACL,EAAA,EAAI,IAAA;AAAA,IACJ,MAAM,MAAA,CAAO,IAAA;AAAA,IACb,GAAA,EAAK,gBAAgB,MAAM,CAAA;AAAA,IAC3B,KAAA;AAAA,IACA,KAAA,EAAO,OAAA,CAAQ,MAAA,EAAQ,MAAM,CAAA;AAAA,IAC7B,SAAS,aAAA,CAAc,YAAA,GAAe,OAAO,WAAA,GAAc,MAAA,CAAO,MAAM,eAAe;AAAA,GACzF;AACF;AAEA,SAAS,UAAA,CAAW,KAAc,SAAA,EAAmC;AACnE,EAAA,IAAI,GAAA,YAAe,gBAAgB,OAAO,GAAA;AAC1C,EAAA,MAAM,OAAQ,GAAA,EAAmC,IAAA;AACjD,EAAA,IAAI,IAAA,KAAS,YAAA,IAAgB,IAAA,KAAS,cAAA,EAAgB;AACpD,IAAA,OAAO,IAAI,cAAA,CAAe,SAAA,EAAW,EAAE,UAAA,EAAY,WAAW,CAAA;AAAA,EAChE;AACA,EAAA,OAAO,IAAI,eAAe,cAAA,EAAgB;AAAA,IACxC,UAAA,EAAY,OAAO,IAAA,KAAS,QAAA,GAAW,IAAA,GAAO;AAAA,GAC/C,CAAA;AACH;AAEA,eAAe,YAAA,CACb,QACA,QAAA,EACkC;AAClC,EAAA,MAAM,UAAA,GAAa,IAAI,eAAA,EAAgB;AACvC,EAAA,MAAM,QAAQ,UAAA,CAAW,MAAM,WAAW,KAAA,EAAM,EAAG,SAAS,SAAS,CAAA;AACrE,EAAA,IAAI;AACF,IAAA,MAAM,WAAW,MAAM,QAAA,CAAS,SAAA,CAAU,SAAA,CAAU,MAAM,CAAA,EAAG;AAAA,MAC3D,MAAA,EAAQ,KAAA;AAAA,MACR,QAAA,EAAU,QAAA;AAAA,MACV,QAAQ,UAAA,CAAW,MAAA;AAAA,MACnB,SAAS,QAAA,CAAS;AAAA,KACnB,CAAA;AACD,IAAA,WAAA,CAAY,QAAA,EAAU,QAAA,CAAS,GAAA,EAAK,CAAA;AACpC,IAAA,MAAM,IAAA,GAAO,MAAM,cAAA,CAAe,QAAQ,CAAA;AAC1C,IAAA,OAAO,UAAA,CAAW,MAAA,EAAQ,IAAA,EAAM,QAAA,CAAS,eAAe,CAAA;AAAA,EAC1D,CAAA,SAAE;AACA,IAAA,YAAA,CAAa,KAAK,CAAA;AAAA,EACpB;AACF;AAEA,eAAe,OAAA,CAAQ,KAAa,QAAA,EAAsD;AACxF,EAAA,MAAM,MAAA,GAAS,eAAe,GAAG,CAAA;AACjC,EAAA,IAAI,MAAA,KAAW,QAAW,OAAO,EAAE,IAAI,KAAA,EAAO,KAAA,EAAO,mBAAmB,GAAA,EAAI;AAC5E,EAAA,IAAI;AACF,IAAA,OAAO,MAAM,YAAA,CAAa,MAAA,EAAQ,QAAQ,CAAA;AAAA,EAC5C,SAAS,GAAA,EAAK;AACZ,IAAA,MAAM,OAAA,GAAU,UAAA,CAAW,GAAA,EAAK,QAAA,CAAS,SAAS,CAAA;AAClD,IAAA,OAAO,EAAE,IAAI,KAAA,EAAO,KAAA,EAAO,QAAQ,KAAA,EAAO,GAAA,EAAK,GAAG,OAAA,CAAQ,KAAA,EAAM;AAAA,EAClE;AACF;AAeO,SAAS,4BAA4B,IAAA,EAAuD;AACjG,EAAA,MAAM,QAAA,GAAqB;AAAA,IACzB,SAAA,EAAW,MAAM,SAAA,IAAa,KAAA;AAAA,IAC9B,OAAA,EAAS,aAAa,IAAA,EAAM,KAAA,KAAU,SAAY,MAAA,GAAY,iBAAA,CAAkB,IAAA,CAAK,KAAK,CAAC,CAAA;AAAA,IAC3F,SAAA,EAAW,sBAAA,CAAuB,WAAA,EAAa,IAAA,EAAM,aAAa,kBAAkB,CAAA;AAAA,IACpF,eAAA,EAAiB,sBAAA;AAAA,MACf,iBAAA;AAAA,MACA,MAAM,eAAA,IAAmB;AAAA,KAC3B;AAAA,IACA,GAAA,EAAK,IAAA,EAAM,GAAA,IAAO,IAAA,CAAK;AAAA,GACzB;AAEA,EAAA,OAAOA,SAAK,MAAA,CAAO;AAAA,IACjB,IAAA,EAAM,MAAM,IAAA,IAAQ,qBAAA;AAAA,IACpB,WAAA,EACE,MAAM,WAAA,IACN,kVAAA;AAAA,IAIF,WAAA,EAAaC,MAAE,MAAA,CAAO;AAAA,MACpB,GAAA,EAAKA,MAAE,MAAA,EAAO,CAAE,IAAI,CAAC,CAAA,CAAE,SAAS,qDAAqD;AAAA,KACtF,CAAA;AAAA,IACD,OAAA,EAAS,OAAO,EAAE,GAAA,EAAI,KAAM,IAAA,CAAK,SAAA,CAAU,MAAM,OAAA,CAAQ,GAAA,EAAK,QAAQ,CAAC;AAAA,GACxE,CAAA;AACH","file":"github-link-preview.cjs","sourcesContent":["/**\n * `github_link_preview` — turn a github.com issue, pull request or repository URL into its title,\n * state and summary.\n *\n * The model's URL is parsed, never fetched: owner, repository and number are validated against\n * GitHub's own name charset and the request always goes to a fixed `https://api.github.com`\n * endpoint built from those parts (ADR D1). Redirects are refused, so a response can never choose\n * the next host or carry the bearer token elsewhere (ADR D2).\n *\n * Return shape (always a JSON string):\n * - `{ ok: true, kind, url, title, state, summary }`\n * - `{ ok: false, error: 'unsupported_url' | 'redirect_blocked' | 'not_found' | 'rate_limited' |\n * 'upstream_error' | 'timeout' | 'fetch_failed' | 'invalid_response', url, ... }`\n */\n\nimport type { CustomTool } from \"@theokit/sdk\";\n\nimport { Tool } from \"@theokit/sdk\";\nimport { z } from \"zod\";\n\nconst API_BASE = \"https://api.github.com\";\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_SUMMARY_CHARS = 500;\nconst MAX_BODY_BYTES = 1_048_576;\nconst GITHUB_HOSTS = new Set([\"github.com\", \"www.github.com\"]);\nconst OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;\nconst REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;\nconst NUMBER_PATTERN = /^[1-9][0-9]{0,9}$/;\n\n/** What a previewed GitHub URL points at, taken from its path. */\nexport type GithubLinkKind = \"repository\" | \"issue\" | \"pull_request\";\n\n/** Options for {@link createGithubLinkPreviewTool}; every one is optional. */\nexport interface CreateGithubLinkPreviewToolOptions {\n /** Fetch implementation with the global `fetch` signature (injectable for tests). */\n fetchImpl?: typeof fetch;\n /** Optional GitHub token, sent as `Authorization: Bearer` to api.github.com only. */\n token?: string;\n /** Name exposed to the model. Default `github_link_preview`. */\n name?: string;\n /** Description exposed to the model. */\n description?: string;\n /** Timeout covering the request and the body read, in ms. Default 10000. */\n timeoutMs?: number;\n /** Longest summary returned, in code points. Default 500. */\n maxSummaryChars?: number;\n /** Clock for `retry_after_seconds` (injectable for tests). Default `Date.now`. */\n now?: () => number;\n}\n\n/** The JSON the `github_link_preview` tool returns: a preview, or a typed failure. */\nexport type GithubLinkPreviewResult =\n | {\n ok: true;\n kind: GithubLinkKind;\n url: string;\n title: string;\n state: string;\n summary: string;\n }\n | {\n ok: false;\n error:\n | \"unsupported_url\"\n | \"redirect_blocked\"\n | \"not_found\"\n | \"rate_limited\"\n | \"upstream_error\"\n | \"timeout\"\n | \"fetch_failed\"\n | \"invalid_response\";\n url: string;\n status?: number;\n retry_after_seconds?: number;\n timeout_ms?: number;\n error_name?: string;\n };\n\ninterface GithubTarget {\n kind: GithubLinkKind;\n owner: string;\n repo: string;\n number?: string;\n}\n\ninterface Settings {\n fetchImpl: typeof fetch;\n headers: Record<string, string>;\n timeoutMs: number;\n maxSummaryChars: number;\n now: () => number;\n}\n\ntype Failure = Extract<GithubLinkPreviewResult, { ok: false }>;\ntype FailureError = Failure[\"error\"];\n\nclass PreviewFailure extends Error {\n constructor(\n readonly error: FailureError,\n readonly extra: Omit<Failure, \"ok\" | \"error\" | \"url\"> = {},\n ) {\n super(error);\n }\n}\n\nfunction requirePositiveInteger(name: string, value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new RangeError(\n `createGithubLinkPreviewTool: ${name} must be a positive integer, got ${value}`,\n );\n }\n return value;\n}\n\nfunction hasControlCharacter(text: string): boolean {\n return Array.from(text).some((char) => {\n const code = char.charCodeAt(0);\n return code < 0x20 || code === 0x7f;\n });\n}\n\nfunction requireValidToken(token: string): string {\n if (token.trim() === \"\" || hasControlCharacter(token)) {\n throw new RangeError(\n \"createGithubLinkPreviewTool: token must be non-blank and free of control characters\",\n );\n }\n return token;\n}\n\nfunction buildHeaders(token: string | undefined): Record<string, string> {\n const headers: Record<string, string> = {\n accept: \"application/vnd.github+json\",\n \"user-agent\": \"theokit-sdk-tools\",\n \"x-github-api-version\": \"2022-11-28\",\n };\n if (token !== undefined) headers.authorization = `Bearer ${token}`;\n return headers;\n}\n\nfunction isGithubOrigin(parsed: URL): boolean {\n return (\n parsed.protocol === \"https:\" &&\n GITHUB_HOSTS.has(parsed.hostname) &&\n parsed.username === \"\" &&\n parsed.password === \"\" &&\n parsed.port === \"\"\n );\n}\n\nconst SECTION_KINDS: Readonly<Record<string, GithubLinkKind>> = {\n issues: \"issue\",\n pull: \"pull_request\",\n};\n\nfunction isValidRepository(owner: string, repo: string): boolean {\n return OWNER_PATTERN.test(owner) && REPO_PATTERN.test(repo) && repo !== \".\" && repo !== \"..\";\n}\n\nfunction numberedTarget(owner: string, repo: string, segments: string[]): GithubTarget | undefined {\n const [, , section = \"\", number = \"\"] = segments;\n const kind = Object.hasOwn(SECTION_KINDS, section) ? SECTION_KINDS[section] : undefined;\n if (segments.length !== 4 || kind === undefined || !NUMBER_PATTERN.test(number)) return undefined;\n return { kind, owner, repo, number };\n}\n\nfunction targetFromSegments(segments: string[]): GithubTarget | undefined {\n const [owner = \"\", repo = \"\"] = segments;\n if (!isValidRepository(owner, repo)) return undefined;\n if (segments.length === 2) return { kind: \"repository\", owner, repo };\n return numberedTarget(owner, repo, segments);\n}\n\nfunction parseGithubUrl(url: string): GithubTarget | undefined {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n return undefined;\n }\n if (!isGithubOrigin(parsed)) return undefined;\n const segments = parsed.pathname.split(\"/\").slice(1);\n if (segments.at(-1) === \"\") segments.pop();\n return targetFromSegments(segments);\n}\n\nfunction apiUrlFor(target: GithubTarget): string {\n const base = `${API_BASE}/repos/${target.owner}/${target.repo}`;\n if (target.kind === \"issue\") return `${base}/issues/${target.number}`;\n if (target.kind === \"pull_request\") return `${base}/pulls/${target.number}`;\n return base;\n}\n\nfunction canonicalUrlFor(target: GithubTarget): string {\n const base = `https://github.com/${target.owner}/${target.repo}`;\n if (target.kind === \"issue\") return `${base}/issues/${target.number}`;\n if (target.kind === \"pull_request\") return `${base}/pull/${target.number}`;\n return base;\n}\n\n// `retry-after` is read as delta-seconds only; an HTTP-date value falls through to the epoch reset.\nfunction retryAfterSeconds(headers: Headers, nowMs: number): number | undefined {\n const retryAfter = Number(headers.get(\"retry-after\") ?? Number.NaN);\n if (Number.isFinite(retryAfter)) return Math.max(0, Math.ceil(retryAfter));\n const reset = Number(headers.get(\"x-ratelimit-reset\") ?? Number.NaN);\n if (Number.isFinite(reset)) return Math.max(0, Math.ceil(reset - nowMs / 1000));\n return undefined;\n}\n\nfunction isRateLimited(response: Response): boolean {\n if (response.status !== 403 && response.status !== 429) return false;\n return (\n response.headers.get(\"x-ratelimit-remaining\") === \"0\" || response.headers.has(\"retry-after\")\n );\n}\n\nfunction checkStatus(response: Response, nowMs: number): void {\n const { status } = response;\n if (status >= 200 && status < 300) return;\n if (status >= 300 && status < 400) throw new PreviewFailure(\"redirect_blocked\", { status });\n if (status === 404) throw new PreviewFailure(\"not_found\", { status });\n if (isRateLimited(response)) {\n const retry = retryAfterSeconds(response.headers, nowMs);\n throw new PreviewFailure(\"rate_limited\", {\n status,\n ...(retry === undefined ? {} : { retry_after_seconds: retry }),\n });\n }\n throw new PreviewFailure(\"upstream_error\", { status });\n}\n\nasync function readCappedJson(response: Response): Promise<unknown> {\n if (Number(response.headers.get(\"content-length\") ?? 0) > MAX_BODY_BYTES) {\n throw new PreviewFailure(\"invalid_response\");\n }\n const text = await response.text();\n if (Buffer.byteLength(text, \"utf8\") > MAX_BODY_BYTES) {\n throw new PreviewFailure(\"invalid_response\");\n }\n try {\n return JSON.parse(text) as unknown;\n } catch {\n throw new PreviewFailure(\"invalid_response\");\n }\n}\n\nfunction cutCodePoints(text: unknown, max: number): string {\n if (typeof text !== \"string\") return \"\";\n return Array.from(text).slice(0, max).join(\"\");\n}\n\nfunction stateOf(target: GithubTarget, body: Record<string, unknown>): string {\n if (target.kind === \"repository\") return body.archived === true ? \"archived\" : \"active\";\n if (target.kind === \"pull_request\" && body.merged === true) return \"merged\";\n if (typeof body.state !== \"string\") throw new PreviewFailure(\"invalid_response\");\n return body.state;\n}\n\nfunction mapPreview(\n target: GithubTarget,\n body: unknown,\n maxSummaryChars: number,\n): GithubLinkPreviewResult {\n if (typeof body !== \"object\" || body === null || Array.isArray(body)) {\n throw new PreviewFailure(\"invalid_response\");\n }\n const record = body as Record<string, unknown>;\n const isRepository = target.kind === \"repository\";\n const title = isRepository ? record.full_name : record.title;\n if (typeof title !== \"string\") throw new PreviewFailure(\"invalid_response\");\n return {\n ok: true,\n kind: target.kind,\n url: canonicalUrlFor(target),\n title,\n state: stateOf(target, record),\n summary: cutCodePoints(isRepository ? record.description : record.body, maxSummaryChars),\n };\n}\n\nfunction failureFor(err: unknown, timeoutMs: number): PreviewFailure {\n if (err instanceof PreviewFailure) return err;\n const name = (err as { name?: unknown } | null)?.name;\n if (name === \"AbortError\" || name === \"TimeoutError\") {\n return new PreviewFailure(\"timeout\", { timeout_ms: timeoutMs });\n }\n return new PreviewFailure(\"fetch_failed\", {\n error_name: typeof name === \"string\" ? name : \"unknown\",\n });\n}\n\nasync function fetchPreview(\n target: GithubTarget,\n settings: Settings,\n): Promise<GithubLinkPreviewResult> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), settings.timeoutMs);\n try {\n const response = await settings.fetchImpl(apiUrlFor(target), {\n method: \"GET\",\n redirect: \"manual\",\n signal: controller.signal,\n headers: settings.headers,\n });\n checkStatus(response, settings.now());\n const body = await readCappedJson(response);\n return mapPreview(target, body, settings.maxSummaryChars);\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function preview(url: string, settings: Settings): Promise<GithubLinkPreviewResult> {\n const target = parseGithubUrl(url);\n if (target === undefined) return { ok: false, error: \"unsupported_url\", url };\n try {\n return await fetchPreview(target, settings);\n } catch (err) {\n const failure = failureFor(err, settings.timeoutMs);\n return { ok: false, error: failure.error, url, ...failure.extra };\n }\n}\n\n/**\n * Build the `github_link_preview` tool. Every option is optional: `createGithubLinkPreviewTool()`\n * previews public objects anonymously with a 10-second timeout and a 500-character summary.\n *\n * Only three URL shapes are accepted: `https://github.com/<owner>/<repo>`,\n * `.../issues/<n>` and `.../pull/<n>` (also on `www.github.com`; a trailing slash, query or\n * fragment is ignored). Anything else is `unsupported_url` and makes no request.\n *\n * Throws `RangeError` at construction for a non-positive or fractional `timeoutMs` or\n * `maxSummaryChars`, and for a blank token or one with a control character. Every failure after\n * construction is a typed `{ ok: false, error }` result, never a throw. The token is never\n * echoed: error bodies are not returned, and `fetch_failed` carries only the error's name.\n */\nexport function createGithubLinkPreviewTool(opts?: CreateGithubLinkPreviewToolOptions): CustomTool {\n const settings: Settings = {\n fetchImpl: opts?.fetchImpl ?? fetch,\n headers: buildHeaders(opts?.token === undefined ? undefined : requireValidToken(opts.token)),\n timeoutMs: requirePositiveInteger(\"timeoutMs\", opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS),\n maxSummaryChars: requirePositiveInteger(\n \"maxSummaryChars\",\n opts?.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS,\n ),\n now: opts?.now ?? Date.now,\n };\n\n return Tool.create({\n name: opts?.name ?? \"github_link_preview\",\n description:\n opts?.description ??\n \"Preview a GitHub issue, pull request or repository from its github.com URL. Returns \" +\n \"{ ok, kind, url, title, state, summary } where state is open/closed/merged for issues and \" +\n \"pull requests and active/archived for repositories, or { ok: false, error }. Only \" +\n \"https://github.com/<owner>/<repo>, .../issues/<n> and .../pull/<n> are accepted.\",\n inputSchema: z.object({\n url: z.string().min(1).describe(\"A github.com repository, issue or pull request URL.\"),\n }),\n handler: async ({ url }) => JSON.stringify(await preview(url, settings)),\n });\n}\n"]}
@@ -0,0 +1,69 @@
1
+ import { CustomTool } from '@theokit/sdk';
2
+
3
+ /**
4
+ * `github_link_preview` — turn a github.com issue, pull request or repository URL into its title,
5
+ * state and summary.
6
+ *
7
+ * The model's URL is parsed, never fetched: owner, repository and number are validated against
8
+ * GitHub's own name charset and the request always goes to a fixed `https://api.github.com`
9
+ * endpoint built from those parts (ADR D1). Redirects are refused, so a response can never choose
10
+ * the next host or carry the bearer token elsewhere (ADR D2).
11
+ *
12
+ * Return shape (always a JSON string):
13
+ * - `{ ok: true, kind, url, title, state, summary }`
14
+ * - `{ ok: false, error: 'unsupported_url' | 'redirect_blocked' | 'not_found' | 'rate_limited' |
15
+ * 'upstream_error' | 'timeout' | 'fetch_failed' | 'invalid_response', url, ... }`
16
+ */
17
+
18
+ /** What a previewed GitHub URL points at, taken from its path. */
19
+ type GithubLinkKind = "repository" | "issue" | "pull_request";
20
+ /** Options for {@link createGithubLinkPreviewTool}; every one is optional. */
21
+ interface CreateGithubLinkPreviewToolOptions {
22
+ /** Fetch implementation with the global `fetch` signature (injectable for tests). */
23
+ fetchImpl?: typeof fetch;
24
+ /** Optional GitHub token, sent as `Authorization: Bearer` to api.github.com only. */
25
+ token?: string;
26
+ /** Name exposed to the model. Default `github_link_preview`. */
27
+ name?: string;
28
+ /** Description exposed to the model. */
29
+ description?: string;
30
+ /** Timeout covering the request and the body read, in ms. Default 10000. */
31
+ timeoutMs?: number;
32
+ /** Longest summary returned, in code points. Default 500. */
33
+ maxSummaryChars?: number;
34
+ /** Clock for `retry_after_seconds` (injectable for tests). Default `Date.now`. */
35
+ now?: () => number;
36
+ }
37
+ /** The JSON the `github_link_preview` tool returns: a preview, or a typed failure. */
38
+ type GithubLinkPreviewResult = {
39
+ ok: true;
40
+ kind: GithubLinkKind;
41
+ url: string;
42
+ title: string;
43
+ state: string;
44
+ summary: string;
45
+ } | {
46
+ ok: false;
47
+ error: "unsupported_url" | "redirect_blocked" | "not_found" | "rate_limited" | "upstream_error" | "timeout" | "fetch_failed" | "invalid_response";
48
+ url: string;
49
+ status?: number;
50
+ retry_after_seconds?: number;
51
+ timeout_ms?: number;
52
+ error_name?: string;
53
+ };
54
+ /**
55
+ * Build the `github_link_preview` tool. Every option is optional: `createGithubLinkPreviewTool()`
56
+ * previews public objects anonymously with a 10-second timeout and a 500-character summary.
57
+ *
58
+ * Only three URL shapes are accepted: `https://github.com/<owner>/<repo>`,
59
+ * `.../issues/<n>` and `.../pull/<n>` (also on `www.github.com`; a trailing slash, query or
60
+ * fragment is ignored). Anything else is `unsupported_url` and makes no request.
61
+ *
62
+ * Throws `RangeError` at construction for a non-positive or fractional `timeoutMs` or
63
+ * `maxSummaryChars`, and for a blank token or one with a control character. Every failure after
64
+ * construction is a typed `{ ok: false, error }` result, never a throw. The token is never
65
+ * echoed: error bodies are not returned, and `fetch_failed` carries only the error's name.
66
+ */
67
+ declare function createGithubLinkPreviewTool(opts?: CreateGithubLinkPreviewToolOptions): CustomTool;
68
+
69
+ export { type CreateGithubLinkPreviewToolOptions, type GithubLinkKind, type GithubLinkPreviewResult, createGithubLinkPreviewTool };
@@ -0,0 +1,69 @@
1
+ import { CustomTool } from '@theokit/sdk';
2
+
3
+ /**
4
+ * `github_link_preview` — turn a github.com issue, pull request or repository URL into its title,
5
+ * state and summary.
6
+ *
7
+ * The model's URL is parsed, never fetched: owner, repository and number are validated against
8
+ * GitHub's own name charset and the request always goes to a fixed `https://api.github.com`
9
+ * endpoint built from those parts (ADR D1). Redirects are refused, so a response can never choose
10
+ * the next host or carry the bearer token elsewhere (ADR D2).
11
+ *
12
+ * Return shape (always a JSON string):
13
+ * - `{ ok: true, kind, url, title, state, summary }`
14
+ * - `{ ok: false, error: 'unsupported_url' | 'redirect_blocked' | 'not_found' | 'rate_limited' |
15
+ * 'upstream_error' | 'timeout' | 'fetch_failed' | 'invalid_response', url, ... }`
16
+ */
17
+
18
+ /** What a previewed GitHub URL points at, taken from its path. */
19
+ type GithubLinkKind = "repository" | "issue" | "pull_request";
20
+ /** Options for {@link createGithubLinkPreviewTool}; every one is optional. */
21
+ interface CreateGithubLinkPreviewToolOptions {
22
+ /** Fetch implementation with the global `fetch` signature (injectable for tests). */
23
+ fetchImpl?: typeof fetch;
24
+ /** Optional GitHub token, sent as `Authorization: Bearer` to api.github.com only. */
25
+ token?: string;
26
+ /** Name exposed to the model. Default `github_link_preview`. */
27
+ name?: string;
28
+ /** Description exposed to the model. */
29
+ description?: string;
30
+ /** Timeout covering the request and the body read, in ms. Default 10000. */
31
+ timeoutMs?: number;
32
+ /** Longest summary returned, in code points. Default 500. */
33
+ maxSummaryChars?: number;
34
+ /** Clock for `retry_after_seconds` (injectable for tests). Default `Date.now`. */
35
+ now?: () => number;
36
+ }
37
+ /** The JSON the `github_link_preview` tool returns: a preview, or a typed failure. */
38
+ type GithubLinkPreviewResult = {
39
+ ok: true;
40
+ kind: GithubLinkKind;
41
+ url: string;
42
+ title: string;
43
+ state: string;
44
+ summary: string;
45
+ } | {
46
+ ok: false;
47
+ error: "unsupported_url" | "redirect_blocked" | "not_found" | "rate_limited" | "upstream_error" | "timeout" | "fetch_failed" | "invalid_response";
48
+ url: string;
49
+ status?: number;
50
+ retry_after_seconds?: number;
51
+ timeout_ms?: number;
52
+ error_name?: string;
53
+ };
54
+ /**
55
+ * Build the `github_link_preview` tool. Every option is optional: `createGithubLinkPreviewTool()`
56
+ * previews public objects anonymously with a 10-second timeout and a 500-character summary.
57
+ *
58
+ * Only three URL shapes are accepted: `https://github.com/<owner>/<repo>`,
59
+ * `.../issues/<n>` and `.../pull/<n>` (also on `www.github.com`; a trailing slash, query or
60
+ * fragment is ignored). Anything else is `unsupported_url` and makes no request.
61
+ *
62
+ * Throws `RangeError` at construction for a non-positive or fractional `timeoutMs` or
63
+ * `maxSummaryChars`, and for a blank token or one with a control character. Every failure after
64
+ * construction is a typed `{ ok: false, error }` result, never a throw. The token is never
65
+ * echoed: error bodies are not returned, and `fetch_failed` carries only the error's name.
66
+ */
67
+ declare function createGithubLinkPreviewTool(opts?: CreateGithubLinkPreviewToolOptions): CustomTool;
68
+
69
+ export { type CreateGithubLinkPreviewToolOptions, type GithubLinkKind, type GithubLinkPreviewResult, createGithubLinkPreviewTool };
@@ -0,0 +1,225 @@
1
+ import { Tool } from '@theokit/sdk';
2
+ import { z } from 'zod';
3
+
4
+ // src/github-link-preview.ts
5
+ var API_BASE = "https://api.github.com";
6
+ var DEFAULT_TIMEOUT_MS = 1e4;
7
+ var DEFAULT_MAX_SUMMARY_CHARS = 500;
8
+ var MAX_BODY_BYTES = 1048576;
9
+ var GITHUB_HOSTS = /* @__PURE__ */ new Set(["github.com", "www.github.com"]);
10
+ var OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
11
+ var REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
12
+ var NUMBER_PATTERN = /^[1-9][0-9]{0,9}$/;
13
+ var PreviewFailure = class extends Error {
14
+ constructor(error, extra = {}) {
15
+ super(error);
16
+ this.error = error;
17
+ this.extra = extra;
18
+ }
19
+ error;
20
+ extra;
21
+ };
22
+ function requirePositiveInteger(name, value) {
23
+ if (!Number.isInteger(value) || value <= 0) {
24
+ throw new RangeError(
25
+ `createGithubLinkPreviewTool: ${name} must be a positive integer, got ${value}`
26
+ );
27
+ }
28
+ return value;
29
+ }
30
+ function hasControlCharacter(text) {
31
+ return Array.from(text).some((char) => {
32
+ const code = char.charCodeAt(0);
33
+ return code < 32 || code === 127;
34
+ });
35
+ }
36
+ function requireValidToken(token) {
37
+ if (token.trim() === "" || hasControlCharacter(token)) {
38
+ throw new RangeError(
39
+ "createGithubLinkPreviewTool: token must be non-blank and free of control characters"
40
+ );
41
+ }
42
+ return token;
43
+ }
44
+ function buildHeaders(token) {
45
+ const headers = {
46
+ accept: "application/vnd.github+json",
47
+ "user-agent": "theokit-sdk-tools",
48
+ "x-github-api-version": "2022-11-28"
49
+ };
50
+ if (token !== void 0) headers.authorization = `Bearer ${token}`;
51
+ return headers;
52
+ }
53
+ function isGithubOrigin(parsed) {
54
+ return parsed.protocol === "https:" && GITHUB_HOSTS.has(parsed.hostname) && parsed.username === "" && parsed.password === "" && parsed.port === "";
55
+ }
56
+ var SECTION_KINDS = {
57
+ issues: "issue",
58
+ pull: "pull_request"
59
+ };
60
+ function isValidRepository(owner, repo) {
61
+ return OWNER_PATTERN.test(owner) && REPO_PATTERN.test(repo) && repo !== "." && repo !== "..";
62
+ }
63
+ function numberedTarget(owner, repo, segments) {
64
+ const [, , section = "", number = ""] = segments;
65
+ const kind = Object.hasOwn(SECTION_KINDS, section) ? SECTION_KINDS[section] : void 0;
66
+ if (segments.length !== 4 || kind === void 0 || !NUMBER_PATTERN.test(number)) return void 0;
67
+ return { kind, owner, repo, number };
68
+ }
69
+ function targetFromSegments(segments) {
70
+ const [owner = "", repo = ""] = segments;
71
+ if (!isValidRepository(owner, repo)) return void 0;
72
+ if (segments.length === 2) return { kind: "repository", owner, repo };
73
+ return numberedTarget(owner, repo, segments);
74
+ }
75
+ function parseGithubUrl(url) {
76
+ let parsed;
77
+ try {
78
+ parsed = new URL(url);
79
+ } catch {
80
+ return void 0;
81
+ }
82
+ if (!isGithubOrigin(parsed)) return void 0;
83
+ const segments = parsed.pathname.split("/").slice(1);
84
+ if (segments.at(-1) === "") segments.pop();
85
+ return targetFromSegments(segments);
86
+ }
87
+ function apiUrlFor(target) {
88
+ const base = `${API_BASE}/repos/${target.owner}/${target.repo}`;
89
+ if (target.kind === "issue") return `${base}/issues/${target.number}`;
90
+ if (target.kind === "pull_request") return `${base}/pulls/${target.number}`;
91
+ return base;
92
+ }
93
+ function canonicalUrlFor(target) {
94
+ const base = `https://github.com/${target.owner}/${target.repo}`;
95
+ if (target.kind === "issue") return `${base}/issues/${target.number}`;
96
+ if (target.kind === "pull_request") return `${base}/pull/${target.number}`;
97
+ return base;
98
+ }
99
+ function retryAfterSeconds(headers, nowMs) {
100
+ const retryAfter = Number(headers.get("retry-after") ?? Number.NaN);
101
+ if (Number.isFinite(retryAfter)) return Math.max(0, Math.ceil(retryAfter));
102
+ const reset = Number(headers.get("x-ratelimit-reset") ?? Number.NaN);
103
+ if (Number.isFinite(reset)) return Math.max(0, Math.ceil(reset - nowMs / 1e3));
104
+ return void 0;
105
+ }
106
+ function isRateLimited(response) {
107
+ if (response.status !== 403 && response.status !== 429) return false;
108
+ return response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after");
109
+ }
110
+ function checkStatus(response, nowMs) {
111
+ const { status } = response;
112
+ if (status >= 200 && status < 300) return;
113
+ if (status >= 300 && status < 400) throw new PreviewFailure("redirect_blocked", { status });
114
+ if (status === 404) throw new PreviewFailure("not_found", { status });
115
+ if (isRateLimited(response)) {
116
+ const retry = retryAfterSeconds(response.headers, nowMs);
117
+ throw new PreviewFailure("rate_limited", {
118
+ status,
119
+ ...retry === void 0 ? {} : { retry_after_seconds: retry }
120
+ });
121
+ }
122
+ throw new PreviewFailure("upstream_error", { status });
123
+ }
124
+ async function readCappedJson(response) {
125
+ if (Number(response.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) {
126
+ throw new PreviewFailure("invalid_response");
127
+ }
128
+ const text = await response.text();
129
+ if (Buffer.byteLength(text, "utf8") > MAX_BODY_BYTES) {
130
+ throw new PreviewFailure("invalid_response");
131
+ }
132
+ try {
133
+ return JSON.parse(text);
134
+ } catch {
135
+ throw new PreviewFailure("invalid_response");
136
+ }
137
+ }
138
+ function cutCodePoints(text, max) {
139
+ if (typeof text !== "string") return "";
140
+ return Array.from(text).slice(0, max).join("");
141
+ }
142
+ function stateOf(target, body) {
143
+ if (target.kind === "repository") return body.archived === true ? "archived" : "active";
144
+ if (target.kind === "pull_request" && body.merged === true) return "merged";
145
+ if (typeof body.state !== "string") throw new PreviewFailure("invalid_response");
146
+ return body.state;
147
+ }
148
+ function mapPreview(target, body, maxSummaryChars) {
149
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
150
+ throw new PreviewFailure("invalid_response");
151
+ }
152
+ const record = body;
153
+ const isRepository = target.kind === "repository";
154
+ const title = isRepository ? record.full_name : record.title;
155
+ if (typeof title !== "string") throw new PreviewFailure("invalid_response");
156
+ return {
157
+ ok: true,
158
+ kind: target.kind,
159
+ url: canonicalUrlFor(target),
160
+ title,
161
+ state: stateOf(target, record),
162
+ summary: cutCodePoints(isRepository ? record.description : record.body, maxSummaryChars)
163
+ };
164
+ }
165
+ function failureFor(err, timeoutMs) {
166
+ if (err instanceof PreviewFailure) return err;
167
+ const name = err?.name;
168
+ if (name === "AbortError" || name === "TimeoutError") {
169
+ return new PreviewFailure("timeout", { timeout_ms: timeoutMs });
170
+ }
171
+ return new PreviewFailure("fetch_failed", {
172
+ error_name: typeof name === "string" ? name : "unknown"
173
+ });
174
+ }
175
+ async function fetchPreview(target, settings) {
176
+ const controller = new AbortController();
177
+ const timer = setTimeout(() => controller.abort(), settings.timeoutMs);
178
+ try {
179
+ const response = await settings.fetchImpl(apiUrlFor(target), {
180
+ method: "GET",
181
+ redirect: "manual",
182
+ signal: controller.signal,
183
+ headers: settings.headers
184
+ });
185
+ checkStatus(response, settings.now());
186
+ const body = await readCappedJson(response);
187
+ return mapPreview(target, body, settings.maxSummaryChars);
188
+ } finally {
189
+ clearTimeout(timer);
190
+ }
191
+ }
192
+ async function preview(url, settings) {
193
+ const target = parseGithubUrl(url);
194
+ if (target === void 0) return { ok: false, error: "unsupported_url", url };
195
+ try {
196
+ return await fetchPreview(target, settings);
197
+ } catch (err) {
198
+ const failure = failureFor(err, settings.timeoutMs);
199
+ return { ok: false, error: failure.error, url, ...failure.extra };
200
+ }
201
+ }
202
+ function createGithubLinkPreviewTool(opts) {
203
+ const settings = {
204
+ fetchImpl: opts?.fetchImpl ?? fetch,
205
+ headers: buildHeaders(opts?.token === void 0 ? void 0 : requireValidToken(opts.token)),
206
+ timeoutMs: requirePositiveInteger("timeoutMs", opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS),
207
+ maxSummaryChars: requirePositiveInteger(
208
+ "maxSummaryChars",
209
+ opts?.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS
210
+ ),
211
+ now: opts?.now ?? Date.now
212
+ };
213
+ return Tool.create({
214
+ name: opts?.name ?? "github_link_preview",
215
+ description: opts?.description ?? "Preview a GitHub issue, pull request or repository from its github.com URL. Returns { ok, kind, url, title, state, summary } where state is open/closed/merged for issues and pull requests and active/archived for repositories, or { ok: false, error }. Only https://github.com/<owner>/<repo>, .../issues/<n> and .../pull/<n> are accepted.",
216
+ inputSchema: z.object({
217
+ url: z.string().min(1).describe("A github.com repository, issue or pull request URL.")
218
+ }),
219
+ handler: async ({ url }) => JSON.stringify(await preview(url, settings))
220
+ });
221
+ }
222
+
223
+ export { createGithubLinkPreviewTool };
224
+ //# sourceMappingURL=github-link-preview.js.map
225
+ //# sourceMappingURL=github-link-preview.js.map