mcp-ado-browser 1.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.
@@ -0,0 +1,478 @@
1
+ /**
2
+ * High-level Azure DevOps read client.
3
+ *
4
+ * - Builds CANONICAL real-host URLs (org/project/api-version all injected, never
5
+ * hardcoded) and delegates execution to an AdoTransport (browser or mock).
6
+ * - Normalizes raw ADO JSON into the declared zod output shapes (schemas.ts).
7
+ * - Optional cache (Phase 2) plugs in via the `CachePort` interface.
8
+ *
9
+ * Every method here is READ-ONLY.
10
+ */
11
+ import * as crypto from "node:crypto";
12
+ import { withApiVersion } from "./versions.js";
13
+ import { NotFoundError } from "../errors.js";
14
+ export class AdoClient {
15
+ transport;
16
+ hosts;
17
+ versions;
18
+ /** Default project scope (optional). Most endpoints work org-wide without it. */
19
+ defaultProject;
20
+ cache;
21
+ /** Cached org-wide repository list, for name -> id resolution. */
22
+ repoIndex = null;
23
+ constructor(deps) {
24
+ this.transport = deps.transport;
25
+ this.hosts = deps.hosts;
26
+ this.versions = deps.versions;
27
+ this.defaultProject = deps.project ?? null;
28
+ this.cache = deps.cache ?? null;
29
+ }
30
+ // ---- org-wide discovery (browse everything accessible) -------------------
31
+ /** All projects the user can access (org-level). */
32
+ async listProjects() {
33
+ const url = withApiVersion(`${this.hosts.base("core")}/_apis/projects?$top=500`, this.versions.forArea("core"));
34
+ const { data } = await this.transport.fetchJson(url);
35
+ const items = (data?.value ?? []).map((p) => ({ id: String(p.id), name: str(p.name) ?? String(p.id), state: str(p.state), description: str(p.description), lastUpdateTime: str(p.lastUpdateTime) }));
36
+ return { count: items.length, items };
37
+ }
38
+ /** All repositories the user can access (org-level), optionally filtered to a project. */
39
+ async listRepositories(args) {
40
+ const project = args?.project ?? undefined;
41
+ const base = project ? `${this.hosts.base("core")}/${enc(project)}/_apis/git/repositories` : `${this.hosts.base("core")}/_apis/git/repositories`;
42
+ const url = withApiVersion(base, this.versions.forArea("git"));
43
+ const { data } = await this.transport.fetchJson(url);
44
+ const items = (data?.value ?? []).map((r) => ({ id: String(r.id), name: str(r.name) ?? String(r.id), project: str(r.project?.name), defaultBranch: str(r.defaultBranch), webUrl: str(r.webUrl ?? r.remoteUrl), isDisabled: typeof r.isDisabled === "boolean" ? r.isDisabled : null }));
45
+ return { count: items.length, items };
46
+ }
47
+ /** Resolve a repository identifier (GUID passes through; a name is looked up org-wide). */
48
+ async resolveRepoId(repoIdOrName) {
49
+ if (/^[0-9a-fA-F-]{36}$/.test(repoIdOrName))
50
+ return repoIdOrName;
51
+ if (!this.repoIndex) {
52
+ const list = await this.listRepositories();
53
+ this.repoIndex = new Map();
54
+ for (const r of list.items)
55
+ this.repoIndex.set(r.name.toLowerCase(), { id: r.id, name: r.name, project: r.project ?? "" });
56
+ }
57
+ const hit = this.repoIndex.get(repoIdOrName.toLowerCase());
58
+ if (!hit)
59
+ throw new NotFoundError("repository", repoIdOrName);
60
+ return hit.id;
61
+ }
62
+ // ---- discovery -----------------------------------------------------------
63
+ async connectionData() {
64
+ const url = withApiVersion(`${this.hosts.base("core")}/_apis/connectionData`, this.versions.forArea("core"));
65
+ const { data } = await this.transport.fetchJson(url);
66
+ return data;
67
+ }
68
+ // ---- 1. search_work_items ------------------------------------------------
69
+ async searchWorkItems(args) {
70
+ const top = args.top ?? 50;
71
+ if (args.text && !args.wiql) {
72
+ // Empirical full-text path (may require the Search extension). Falls back to WIQL on failure.
73
+ try {
74
+ return await this.searchWorkItemsAlmSearch(args.text, top);
75
+ }
76
+ catch {
77
+ /* fall through to wiql */
78
+ }
79
+ }
80
+ // WIQL runs ORG-WIDE (cross-project) by default; scope to a project only when one
81
+ // is given. An unconstrained "all work items" query hits the WIQL hard limit
82
+ // (VS402337: >20000 => HTTP 400), so the default is bounded to the caller's items.
83
+ const project = args.project ?? this.defaultProject ?? undefined;
84
+ const scope = project ? `[System.TeamProject] = @project AND ` : "";
85
+ const wiql = args.wiql ?? `SELECT [System.Id] FROM WorkItems WHERE ${scope}[System.AssignedTo] = @Me ORDER BY [System.ChangedDate] DESC`;
86
+ const base = project ? `${this.hosts.base("core")}/${enc(project)}/_apis/wit/wiql` : `${this.hosts.base("core")}/_apis/wit/wiql`;
87
+ const url = withApiVersion(base, this.versions.forArea("wit"));
88
+ const { data } = await this.transport.fetchJson(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: wiql }) });
89
+ const refs = (data?.workItems ?? []).slice(0, top);
90
+ const ids = refs.map((r) => r.id);
91
+ const items = ids.length ? await this.fetchSummaries(ids) : [];
92
+ return { count: items.length, items, backend: "wiql" };
93
+ }
94
+ async searchWorkItemsAlmSearch(text, top) {
95
+ const url = withApiVersion(`${this.hosts.base("search")}/_apis/search/workitemsearchresults`, this.versions.forArea("search"));
96
+ const { data } = await this.transport.fetchJson(url, {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({ searchText: text, $top: top, $skip: 0 }),
100
+ });
101
+ const results = data?.results ?? [];
102
+ const items = results.map((r) => {
103
+ const f = r.fields ?? {};
104
+ return {
105
+ id: Number(f["system.id"] ?? f["System.Id"] ?? r.id),
106
+ type: str(f["system.workitemtype"] ?? f["System.WorkItemType"]),
107
+ title: str(f["system.title"] ?? f["System.Title"]),
108
+ state: str(f["system.state"] ?? f["System.State"]),
109
+ fields: f,
110
+ };
111
+ });
112
+ return { count: items.length, items, backend: "almsearch" };
113
+ }
114
+ async fetchSummaries(ids) {
115
+ const fields = ["System.Id", "System.Title", "System.State", "System.WorkItemType"];
116
+ // workitemsbatch works org-wide (no project segment) — confirmed empirically.
117
+ const url = withApiVersion(`${this.hosts.base("core")}/_apis/wit/workitemsbatch`, this.versions.forArea("wit"));
118
+ const { data } = await this.transport.fetchJson(url, {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify({ ids, fields }),
122
+ });
123
+ return (data?.value ?? []).map((w) => ({
124
+ id: w.id,
125
+ type: str(w.fields?.["System.WorkItemType"]),
126
+ title: str(w.fields?.["System.Title"]),
127
+ state: str(w.fields?.["System.State"]),
128
+ fields: w.fields,
129
+ }));
130
+ }
131
+ // ---- 2. get_work_item ----------------------------------------------------
132
+ /** Canonical URL for a work item with full expand (so `relations` are present). */
133
+ workItemUrl(id) {
134
+ return withApiVersion(`${this.hosts.base("core")}/_apis/wit/workitems/${id}?$expand=all`, this.versions.forArea("wit"));
135
+ }
136
+ async getWorkItem(id, opts) {
137
+ const kind = "workitem";
138
+ const key = String(id);
139
+ // TTL=0 disables caching for this resource: always a full refetch.
140
+ const cachingOn = this.cache && !opts?.bypassCache && this.cache.ttlFor(kind) > 0;
141
+ if (cachingOn) {
142
+ const cached = this.cache.get(kind, key);
143
+ if (cached) {
144
+ const age = (Date.now() - cached.validatedAt) / 1000;
145
+ if (age < this.cache.ttlFor(kind))
146
+ return cached.value; // fresh hit: ZERO network
147
+ // Stale: cheap freshness oracle via workitemsbatch (Rev). If unchanged, keep cache.
148
+ const fresh = await this.freshnessByRev([id]);
149
+ const rev = fresh.get(id);
150
+ if (rev !== undefined && String(rev) === cached.version) {
151
+ this.cache.touch(kind, key); // refresh validation timestamp; NO full fetch
152
+ return cached.value;
153
+ }
154
+ }
155
+ }
156
+ const wi = await this.fetchWorkItemFull(id);
157
+ this.cache?.set(kind, key, wi, String(wi.rev));
158
+ return wi;
159
+ }
160
+ async fetchWorkItemFull(id) {
161
+ const { data } = await this.transport.fetchJson(this.workItemUrl(id));
162
+ return normalizeWorkItem(data);
163
+ }
164
+ /** Batch freshness oracle: returns id -> current Rev using a single workitemsbatch call. */
165
+ async freshnessByRev(ids) {
166
+ const url = withApiVersion(`${this.hosts.base("core")}/_apis/wit/workitemsbatch`, this.versions.forArea("wit"));
167
+ const { data } = await this.transport.fetchJson(url, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ ids, fields: ["System.Rev", "System.ChangedDate"] }),
171
+ });
172
+ const out = new Map();
173
+ for (const w of data?.value ?? [])
174
+ out.set(w.id, w.rev);
175
+ return out;
176
+ }
177
+ // ---- 3. get_work_item_comments ------------------------------------------
178
+ /**
179
+ * The comments endpoint is the ONLY work-item route that requires a project
180
+ * segment (empirically: org-level => 404 "controller not found"). We derive the
181
+ * project dynamically from the work item's System.TeamProject — so the caller
182
+ * never has to know or pass it. `project`/`wi` let callers skip the lookup.
183
+ */
184
+ async getWorkItemComments(id, ctx) {
185
+ const project = ctx?.project ?? str((ctx?.wi ?? (await this.getWorkItem(id))).fields["System.TeamProject"]) ?? this.defaultProject;
186
+ if (!project)
187
+ throw new NotFoundError("project for work item comments", id);
188
+ const url = withApiVersion(`${this.hosts.base("core")}/${enc(project)}/_apis/wit/workItems/${id}/comments`, this.versions.forArea("wit-comments"));
189
+ const { data } = await this.transport.fetchJson(url);
190
+ const comments = (data?.comments ?? []).map(normalizeComment);
191
+ return { workItemId: id, totalCount: data?.totalCount ?? comments.length, count: comments.length, comments };
192
+ }
193
+ // ---- 4. get_comment_details (+ attachments) ------------------------------
194
+ /** Resolve a comment and download all related attachments (work-item AttachedFile + body refs). */
195
+ async getCommentDetails(args) {
196
+ const wi = await this.fetchWorkItemFull(args.workItemId);
197
+ let comment = null;
198
+ if (args.commentId !== undefined) {
199
+ const list = await this.getWorkItemComments(args.workItemId, { wi }); // reuse wi to derive project, no refetch
200
+ comment = list.comments.find((c) => c.id === args.commentId) ?? null;
201
+ if (!comment)
202
+ throw new NotFoundError("comment", args.commentId);
203
+ }
204
+ const refs = collectAttachmentRefs(wi, comment?.text ?? "");
205
+ const attachments = [];
206
+ for (const ref of refs) {
207
+ const bin = await this.transport.fetchBuffer(withApiVersion(ref.url, this.versions.forArea("wit")));
208
+ const sha256 = crypto.createHash("sha256").update(bin.data).digest("hex");
209
+ let savedPath = null;
210
+ if (args.saveDir) {
211
+ const fs = await import("node:fs/promises");
212
+ const path = await import("node:path");
213
+ await fs.mkdir(args.saveDir, { recursive: true });
214
+ savedPath = path.join(args.saveDir, `${ref.guid}-${ref.name.replace(/[/\\]+/g, "_")}`);
215
+ await fs.writeFile(savedPath, bin.data);
216
+ }
217
+ attachments.push({ ...ref, size: bin.data.length, contentLength: bin.contentLength, sha256, savedPath });
218
+ }
219
+ return { workItemId: args.workItemId, comment, attachments };
220
+ }
221
+ // ---- 5. search_pull_requests --------------------------------------------
222
+ async searchPullRequests(args) {
223
+ const top = args.top ?? 50;
224
+ const q = [`$top=${top}`];
225
+ if (args.status)
226
+ q.push(`searchCriteria.status=${enc(args.status)}`);
227
+ if (args.creatorId)
228
+ q.push(`searchCriteria.creatorId=${enc(args.creatorId)}`);
229
+ if (args.targetRef)
230
+ q.push(`searchCriteria.targetRefName=${enc(args.targetRef)}`);
231
+ const project = args.project ?? this.defaultProject ?? undefined;
232
+ // Precedence: a repo (org-level by id) > a project scope > ORG-WIDE search.
233
+ let base;
234
+ if (args.repoId) {
235
+ const repoId = await this.resolveRepoId(args.repoId);
236
+ base = `${this.hosts.base("core")}/_apis/git/repositories/${enc(repoId)}/pullrequests`;
237
+ }
238
+ else if (project) {
239
+ base = `${this.hosts.base("core")}/${enc(project)}/_apis/git/pullrequests`;
240
+ }
241
+ else {
242
+ base = `${this.hosts.base("core")}/_apis/git/pullrequests`;
243
+ }
244
+ const url = withApiVersion(`${base}?${q.join("&")}`, this.versions.forArea("git"));
245
+ const { data } = await this.transport.fetchJson(url);
246
+ const items = (data?.value ?? []).map(normalizePrSummary);
247
+ return { count: items.length, items };
248
+ }
249
+ // ---- 6. get_pull_request -------------------------------------------------
250
+ async getPullRequest(args) {
251
+ const repoId = await this.resolveRepoId(args.repoId);
252
+ const url = withApiVersion(`${this.hosts.base("core")}/_apis/git/repositories/${enc(repoId)}/pullRequests/${args.prId}?$expand=all`, this.versions.forArea("git"));
253
+ const { data } = await this.transport.fetchJson(url);
254
+ let workItemRefs = [];
255
+ try {
256
+ const wiUrl = withApiVersion(`${this.hosts.base("core")}/_apis/git/repositories/${enc(repoId)}/pullRequests/${args.prId}/workitems`, this.versions.forArea("git"));
257
+ const { data: wir } = await this.transport.fetchJson(wiUrl);
258
+ workItemRefs = (wir?.value ?? []).map((w) => ({ id: String(w.id), url: str(w.url) ?? "" }));
259
+ }
260
+ catch {
261
+ /* work-item refs are best-effort */
262
+ }
263
+ return normalizePullRequest(data, workItemRefs);
264
+ }
265
+ // ---- 7. get_pull_request_comments ---------------------------------------
266
+ async getPullRequestComments(args) {
267
+ const repoId = await this.resolveRepoId(args.repoId);
268
+ const url = withApiVersion(`${this.hosts.base("core")}/_apis/git/repositories/${enc(repoId)}/pullRequests/${args.prId}/threads`, this.versions.forArea("git"));
269
+ const { data } = await this.transport.fetchJson(url);
270
+ const threads = (data?.value ?? []).map(normalizeThread);
271
+ const systemThreadCount = threads.filter((t) => t.kind === "system").length;
272
+ return {
273
+ pullRequestId: args.prId,
274
+ threadCount: threads.length,
275
+ systemThreadCount,
276
+ humanThreadCount: threads.length - systemThreadCount,
277
+ threads,
278
+ };
279
+ }
280
+ // ---- 8. search_feeds -----------------------------------------------------
281
+ async searchFeeds(args) {
282
+ const feedsUrl = withApiVersion(`${this.hosts.base("feeds")}/_apis/packaging/feeds`, this.versions.forArea("packaging-feeds"));
283
+ const { data } = await this.transport.fetchJson(feedsUrl);
284
+ const feeds = (data?.value ?? []).map((f) => ({ id: String(f.id), name: str(f.name) ?? String(f.id), url: str(f.url) }));
285
+ if (!args?.feedId)
286
+ return { feeds };
287
+ const pkgUrl = withApiVersion(`${this.hosts.base("feeds")}/_apis/packaging/feeds/${enc(args.feedId)}/packages?includeAllVersions=true`, this.versions.forArea("packaging-feeds"));
288
+ const { data: pkgData } = await this.transport.fetchJson(pkgUrl);
289
+ const packages = (pkgData?.value ?? []).map((p) => ({
290
+ id: String(p.id),
291
+ name: str(p.name) ?? String(p.id),
292
+ protocolType: str(p.protocolType),
293
+ versions: (p.versions ?? []).map((v) => ({ id: str(v.id), version: str(v.version) ?? "", isLatest: typeof v.isLatest === "boolean" ? v.isLatest : null })),
294
+ }));
295
+ return { feeds, packages };
296
+ }
297
+ // ---- 9. download_artifact (Phase 3, cross-host via session) ---------------
298
+ /** Canonical pkgs.dev.azure.com download URL for a package version. */
299
+ artifactUrl(args) {
300
+ if (args.protocol === "npm") {
301
+ // npm tarball download (EMPIRICALLY validated against a live feed):
302
+ // {org}/_packaging/{feed}/npm/registry/{name}/-/{unscopedName}-{version}.tgz
303
+ // The '/npm/registry/' segment is required, the route is ORG-scoped (project-
304
+ // scoped returns "feed doesn't exist"), and the FILENAME uses only the unscoped
305
+ // name ('@scope/name' -> 'name'). The path keeps the scoped name with a literal '/'.
306
+ const n = args.packageName;
307
+ const filename = n.includes("/") ? n.slice(n.lastIndexOf("/") + 1) : n;
308
+ return `${this.hosts.base("pkgs")}/_packaging/${enc(args.feedId)}/npm/registry/${n}/-/${filename}-${enc(args.version)}.tgz`;
309
+ }
310
+ // nuget content endpoint, ORG-scoped (consistent with the validated npm route;
311
+ // feeds are org-level). Unvalidated against a live NuGet feed — npm is the path
312
+ // exercised end-to-end. Falls back to a project segment if one is configured.
313
+ const scope = this.defaultProject ? `/${enc(this.defaultProject)}` : "";
314
+ return withApiVersion(`${this.hosts.base("pkgs")}${scope}/_apis/packaging/feeds/${enc(args.feedId)}/nuget/packages/${enc(args.packageName)}/versions/${enc(args.version)}/content`, this.versions.forArea("packaging-pkgs"));
315
+ }
316
+ async downloadArtifact(args) {
317
+ const url = this.artifactUrl(args);
318
+ const bin = await this.transport.fetchBuffer(url);
319
+ const sha256 = crypto.createHash("sha256").update(bin.data).digest("hex");
320
+ const { validateArchive } = await import("./archive.js");
321
+ const check = validateArchive(args.protocol, bin.data);
322
+ const fs = await import("node:fs/promises");
323
+ const path = await import("node:path");
324
+ await fs.mkdir(args.saveDir, { recursive: true });
325
+ const ext = args.protocol === "npm" ? "tgz" : "nupkg";
326
+ // Sanitize the filename: scoped names (@scope/name) contain '/' which would
327
+ // otherwise be treated as a directory and fail the write.
328
+ const safeName = args.packageName.replace(/[/\\@]+/g, "_").replace(/^_+/, "");
329
+ const savedPath = path.join(args.saveDir, `${safeName}.${args.version}.${ext}`);
330
+ await fs.writeFile(savedPath, bin.data);
331
+ return {
332
+ feedId: args.feedId,
333
+ packageName: args.packageName,
334
+ version: args.version,
335
+ protocol: args.protocol,
336
+ size: bin.data.length,
337
+ contentLength: bin.contentLength,
338
+ sha256,
339
+ savedPath,
340
+ archiveValid: check.valid,
341
+ archiveDetail: check.detail,
342
+ };
343
+ }
344
+ }
345
+ // ---- helpers / normalizers -------------------------------------------------
346
+ function enc(s) {
347
+ return encodeURIComponent(s);
348
+ }
349
+ function str(v) {
350
+ return typeof v === "string" ? v : v == null ? null : String(v);
351
+ }
352
+ /** Parse a PR artifact link: vstfs:///Git/PullRequestId/{projectId}%2F{repoId}%2F{prId} */
353
+ export function parsePrArtifact(url) {
354
+ const idx = url.indexOf("PullRequestId/");
355
+ if (idx === -1)
356
+ return undefined;
357
+ const tail = decodeURIComponent(url.slice(idx + "PullRequestId/".length));
358
+ const parts = tail.split("/");
359
+ if (parts.length < 3)
360
+ return undefined;
361
+ const prId = Number(parts[2]);
362
+ if (!Number.isFinite(prId))
363
+ return undefined;
364
+ return { projectId: parts[0], repositoryId: parts[1], pullRequestId: prId };
365
+ }
366
+ export function normalizeWorkItem(raw) {
367
+ const fields = raw?.fields ?? {};
368
+ const relations = (raw?.relations ?? []).map((r) => {
369
+ const rel = { rel: r.rel, url: r.url, attributes: r.attributes };
370
+ if (typeof r.url === "string") {
371
+ if (r.url.includes("PullRequestId")) {
372
+ const pr = parsePrArtifact(r.url);
373
+ if (pr)
374
+ rel.pullRequest = pr;
375
+ }
376
+ const m = /\/workItems\/(\d+)(?:$|\?)/i.exec(r.url);
377
+ if (m)
378
+ rel.workItemId = Number(m[1]);
379
+ }
380
+ return rel;
381
+ });
382
+ return {
383
+ id: raw.id,
384
+ rev: raw.rev,
385
+ url: str(raw.url) ?? "",
386
+ type: str(fields["System.WorkItemType"]) ?? "",
387
+ title: str(fields["System.Title"]) ?? "",
388
+ state: str(fields["System.State"]) ?? "",
389
+ fields,
390
+ relations,
391
+ };
392
+ }
393
+ function normalizeComment(c) {
394
+ return {
395
+ id: c.id,
396
+ text: str(c.text) ?? "",
397
+ createdBy: str(c.createdBy?.displayName ?? c.createdBy?.uniqueName),
398
+ createdDate: str(c.createdDate),
399
+ modifiedDate: str(c.modifiedDate),
400
+ };
401
+ }
402
+ /** Collect attachment references: work-item AttachedFile relations + comment-body src/href links. */
403
+ export function collectAttachmentRefs(wi, commentText) {
404
+ const out = [];
405
+ const seen = new Set();
406
+ for (const r of wi.relations) {
407
+ if (r.rel === "AttachedFile" && typeof r.url === "string") {
408
+ const guid = guidFromAttachmentUrl(r.url);
409
+ const name = r.attributes?.["name"] ?? guid;
410
+ if (guid && !seen.has(guid)) {
411
+ seen.add(guid);
412
+ out.push({ guid, name, url: r.url, source: "relation" });
413
+ }
414
+ }
415
+ }
416
+ // attachments referenced inside the comment body: .../_apis/wit/attachments/{guid}
417
+ const re = /_apis\/wit\/attachments\/([0-9a-fA-F-]{36})(?:[^"'\s)]*)?/g;
418
+ let m;
419
+ while ((m = re.exec(commentText)) !== null) {
420
+ const guid = m[1];
421
+ if (!seen.has(guid)) {
422
+ seen.add(guid);
423
+ const fileNameMatch = /fileName=([^&"'\s]+)/.exec(m[0]);
424
+ out.push({ guid, name: fileNameMatch ? decodeURIComponent(fileNameMatch[1]) : guid, url: `https://dev.azure.com/_apis/wit/attachments/${guid}`, source: "comment-body" });
425
+ }
426
+ }
427
+ return out;
428
+ }
429
+ function guidFromAttachmentUrl(url) {
430
+ const m = /attachments\/([0-9a-fA-F-]{36})/.exec(url);
431
+ return m ? m[1] : "";
432
+ }
433
+ function normalizePrSummary(p) {
434
+ return {
435
+ pullRequestId: p.pullRequestId,
436
+ title: str(p.title),
437
+ status: str(p.status),
438
+ createdBy: str(p.createdBy?.displayName ?? p.createdBy?.uniqueName),
439
+ sourceRefName: str(p.sourceRefName),
440
+ targetRefName: str(p.targetRefName),
441
+ repositoryId: str(p.repository?.id),
442
+ repositoryName: str(p.repository?.name),
443
+ };
444
+ }
445
+ function normalizePullRequest(p, workItemRefs) {
446
+ return {
447
+ pullRequestId: p.pullRequestId,
448
+ title: str(p.title),
449
+ description: str(p.description),
450
+ status: str(p.status),
451
+ createdBy: str(p.createdBy?.displayName ?? p.createdBy?.uniqueName),
452
+ sourceRefName: str(p.sourceRefName),
453
+ targetRefName: str(p.targetRefName),
454
+ repositoryId: str(p.repository?.id),
455
+ repositoryName: str(p.repository?.name),
456
+ reviewers: (p.reviewers ?? []).map((r) => ({ id: str(r.id), displayName: str(r.displayName), vote: typeof r.vote === "number" ? r.vote : null })),
457
+ workItemRefs,
458
+ raw: p,
459
+ };
460
+ }
461
+ function normalizeThread(t) {
462
+ const comments = (t.comments ?? []).map((c) => ({
463
+ id: c.id,
464
+ content: str(c.content),
465
+ author: str(c.author?.displayName ?? c.author?.uniqueName),
466
+ commentType: str(c.commentType),
467
+ publishedDate: str(c.publishedDate),
468
+ }));
469
+ // A thread is "system" when ALL its comments are system-generated (commentType 'system'),
470
+ // or it carries no human content (properties-only status threads).
471
+ const hasHuman = comments.some((c) => c.commentType && c.commentType !== "system");
472
+ return {
473
+ id: t.id,
474
+ kind: (hasHuman ? "human" : "system"),
475
+ status: str(t.status),
476
+ comments,
477
+ };
478
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Azure DevOps service topology. These are platform-fixed host *templates*
3
+ * parameterized solely by the organization name — NOT org-specific data and
4
+ * NOT hardcoded ids/projects. Centralized here so the URL-spy gates and the
5
+ * "no hardcoded org" grep gate have exactly one place to reason about.
6
+ *
7
+ * Every host below is `<service>.dev.azure.com/{org}` form; the org is injected.
8
+ */
9
+ const HOST_TEMPLATES = {
10
+ core: (org) => `https://dev.azure.com/${org}`,
11
+ feeds: (org) => `https://feeds.dev.azure.com/${org}`,
12
+ pkgs: (org) => `https://pkgs.dev.azure.com/${org}`,
13
+ search: (org) => `https://almsearch.dev.azure.com/${org}`,
14
+ analytics: (org) => `https://analytics.dev.azure.com/${org}`,
15
+ };
16
+ export class HostResolver {
17
+ org;
18
+ constructor(org) {
19
+ this.org = org;
20
+ if (!org)
21
+ throw new Error("HostResolver requires an org");
22
+ }
23
+ base(kind) {
24
+ return HOST_TEMPLATES[kind](this.org);
25
+ }
26
+ /** True if the given absolute URL points at one of the real ADO hosts (used by live gates). */
27
+ static isRealAdoHost(url) {
28
+ try {
29
+ const h = new URL(url).host;
30
+ return /\.dev\.azure\.com$/.test(h) || h === "dev.azure.com";
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ }