SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
4.4 KB · 65 lines typescript
Raw Blame History
1import { sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";2import { httpFetchWithRetry } from "./fetcher";3import { NormalizeError, type WebSensorConnector } from "./types";4import { parseFeed } from "./rss";56/**7 * GitHub connector. Prefers the unauthenticated Atom feeds (`/releases.atom`, `/tags.atom`,8 * `/commits/<branch>.atom`) which are not subject to the 60 req/h REST limit; uses the REST9 * API (with optional GITHUB_TOKEN) only for `security advisories`.10 * Config: { repo: "owner/name", kind: "releases" | "tags" | "commits" | "advisories", branch?: string }11 */12export class GitHubConnector implements WebSensorConnector {13  mode = "list" as const;14  metadata(): ConnectorMetadata {15    return { key: "github", name: "GitHub", sensorTypes: ["GITHUB_RELEASE", "GITHUB_REPO"], description: "Releases, tags, commits (Atom) and security advisories (REST)", version: "1.0.0" };16  }17  private urlFor(endpoint: SensorEndpoint): { url: string; api: boolean } {18    const cfg = endpoint.config as { repo?: string; kind?: string; branch?: string };19    if (!cfg.repo) return { url: endpoint.url, api: /api\.github\.com/.test(endpoint.url) };20    switch (cfg.kind ?? "releases") {21      case "tags":22        return { url: `https://github.com/${cfg.repo}/tags.atom`, api: false };23      case "commits":24        return { url: `https://github.com/${cfg.repo}/commits/${cfg.branch ?? "main"}.atom`, api: false };25      case "advisories":26        return { url: `https://api.github.com/repos/${cfg.repo}/security-advisories?per_page=30`, api: true };27      default:28        return { url: `https://github.com/${cfg.repo}/releases.atom`, api: false };29    }30  }31  async fetch(endpoint: SensorEndpoint): Promise<Observation> {32    const { url, api } = this.urlFor(endpoint);33    const headers: Record<string, string> = {};34    if (api) {35      headers.accept = "application/vnd.github+json";36      headers["x-github-api-version"] = "2022-11-28";37      if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;38    }39    const obs = await httpFetchWithRetry(endpoint.id, url, { etag: endpoint.etag, lastModified: endpoint.lastModified, headers, accept: api ? "application/vnd.github+json" : "application/atom+xml, application/xml;q=0.9" });40    return { ...obs, url: endpoint.url };41  }42  async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {43    if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");44    const text = obs.body.toString("utf8");45    const { api } = this.urlFor(endpoint);46    if (api) {47      let arr: Record<string, unknown>[];48      try {49        arr = JSON.parse(text) as Record<string, unknown>[];50      } catch {51        throw new NormalizeError("bad_json", "GitHub API response is not JSON");52      }53      if (!Array.isArray(arr)) throw new NormalizeError("bad_shape", String((arr as Record<string, unknown>).message ?? "Unexpected GitHub API response"));54      const items = arr.map((a) => ({ key: String(a.ghsa_id ?? a.id), title: `${String(a.ghsa_id ?? "")} ${String(a.summary ?? "")}`.trim(), url: String(a.html_url ?? ""), summary: String(a.description ?? "").slice(0, 1000), severity: String(a.severity ?? ""), cve: String(a.cve_id ?? ""), publishedAt: a.published_at ? new Date(String(a.published_at)).toISOString() : null, state: String(a.state ?? "") }));55      const canonical = items.map((i) => `${i.key}\t${i.state}\t${i.severity}`).join("\n");56      return { mode: "list", items, compareFields: ["state", "severity", "title"], rawHash: sha256(text), canonicalHash: sha256(canonical), semanticHash: simhash(items.map((i) => i.title).join("\n")), publishedAt: items[0]?.publishedAt ? new Date(items[0].publishedAt) : null, extractionConfidence: 1 };57    }58    const feed = parseFeed(text, obs.meta.finalUrl);59    const items = feed.items.slice(0, 60);60    const canonical = items.map((i) => `${i.key}\t${i.title}\t${i.updatedAt ?? ""}`).join("\n");61    const newest = items.map((i) => i.publishedAt).filter((x): x is string => Boolean(x)).sort().at(-1);62    return { mode: "list", items, compareFields: ["title"], title: feed.title, rawHash: sha256(text), canonicalHash: sha256(canonical), semanticHash: simhash(items.map((i) => i.title).join("\n")), publishedAt: newest ? new Date(newest) : null, extractionConfidence: 1, extra: { feedKind: feed.kind } };63  }64}65