redline registry

Derives the register of onboarded repositories by walking the org, from the .redline.json each one carries. Nothing about it is hand-maintained.

What this is

Derives the register of onboarded repositories by walking the organisation and reading the .redline.json each one carries. It is the input both redline sync and the estate dashboard run off — sync needs to know who to open a pull request on, and coverage needs to know the denominator.

How to onboard it

It runs in the source repository, nightly, from workflows/registry.yml — not in a product repository, where it would be meaningless. It needs a token with org read access and nothing more: the register is derived from what each repository already publishes about itself, so nothing here is hand-maintained. A repository that removes Redline stops appearing, and stops being a sync target, without anyone editing a list.

How to use it

terminal
$ npx redlinegate@latest registry --org acme --source acme/redline
$ npx redlinegate@latest registry --org acme --source acme/redline --out registry.json

The flags that change behaviour materially:

  • --org <name>The organisation to walk. Required — there is no default, because a default here would be a guess about whose estate you meant.
  • --source <owner/name>This repository, recorded into the register so a consumer knows which estate the file describes. Required: a registry.json that does not say where it came from is one nobody can safely act on when two of them exist.
  • --token <string>, --out <path>A token with org read access (or GH_TOKEN in the environment), and where to write the file — registry.json by default.

Expected output

A JSON register of every onboarded repository with the profile, vendors, rung, capabilities and standards version each one recorded. Read-only on every repository it walks: deriving the register grants Redline no write access to anything in it.

How to edit it

cli/registry/discover.ts walks the org and reads each .redline.json; serialize.ts is the file shape. The register's contract matters more than its content — cli/sync/plan.ts and scripts/build-dashboard.mjs both consume it, so a field removed here goes missing in two places at once.

Run npm test and npm run typecheck before pushing: the command surface is unit-tested against a fake host client, so a behaviour change shows up as a failing assertion rather than as a surprise on someone's repository.

The full file

cli/registry/discover.ts · 111 lines · 3.5 KB
import { parseConfig } from '../config/redline-json.ts';
import type { GitHubClient } from '../platforms/github/client.ts';
import type { RegistryEntry } from './types.ts';

export interface DiscoveryResult {
  entries: RegistryEntry[];
  // A repository the walk could not turn into an entry. Reported, never
  // thrown: one malformed .redline.json in the estate must not cost the whole
  // register.
  problems: string[];
}

interface RepoNode {
  name: string;
  defaultBranchRef: { name: string } | null;
  object: { text?: string } | null;
}

interface OrgPage {
  // repositoryOwner, not organization: the source repository may live under a
  // user account, and `organization(login:)` returns null for one. This query
  // resolves both.
  repositoryOwner: {
    repositories: {
      nodes: RepoNode[];
      pageInfo: { hasNextPage: boolean; endCursor: string | null };
    };
  } | null;
}

export const DISCOVERY_QUERY = `
query($org: String!, $cursor: String) {
  repositoryOwner(login: $org) {
    repositories(first: 100, after: $cursor, isArchived: false, ownerAffiliations: OWNER) {
      nodes {
        name
        defaultBranchRef { name }
        object(expression: "HEAD:.redline.json") { ... on Blob { text } }
      }
      pageInfo { hasNextPage endCursor }
    }
  }
}`;

// A host that reports hasNextPage: true forever must not hang a nightly job.
// 200 pages of 100 is 20,000 repositories — far past any real estate.
const MAX_PAGES = 200;

export async function discoverGitHub(
  client: Pick<GitHubClient, 'graphql'>,
  org: string,
): Promise<DiscoveryResult> {
  const entries: RegistryEntry[] = [];
  const problems: string[] = [];

  let cursor: string | null = null;
  let pages = 0;

  do {
    const data: OrgPage = await client.graphql<OrgPage>(DISCOVERY_QUERY, { org, cursor });
    const repos = data.repositoryOwner?.repositories;
    if (!repos) {
      problems.push(`${org}: owner not readable with this token`);
      break;
    }

    for (const node of repos.nodes) {
      // No .redline.json is the discovery signal for "not onboarded", not an
      // error: it is why nothing has to write a register on the way in.
      const text = node.object?.text;
      if (!text) continue;
      if (!node.defaultBranchRef) {
        problems.push(`${org}/${node.name}: has .redline.json but no default branch`);
        continue;
      }
      // The estate's own files are external input to this walk, so this is a
      // real boundary and the guard belongs here. Without it one hand-edited
      // config would abort the walk and publish a register missing every
      // repository after it.
      let config;
      try {
        config = parseConfig(JSON.parse(text));
      } catch (error) {
        const detail = error instanceof Error ? error.message : String(error);
        problems.push(`${org}/${node.name}: ${detail}`);
        continue;
      }
      entries.push({
        host: 'github',
        org,
        repo: node.name,
        defaultBranch: node.defaultBranchRef.name,
        profile: config.profile,
        standardsVersion: config.standardsVersion,
        cliVersion: config.cliVersion,
        onboardedAt: config.onboardedAt,
        rung: config.rung,
      });
    }

    cursor = repos.pageInfo.hasNextPage ? repos.pageInfo.endCursor : null;
    pages += 1;
    if (cursor !== null && pages >= MAX_PAGES) {
      problems.push(`${org}: stopped after ${MAX_PAGES} pages — the register may be incomplete`);
      break;
    }
  } while (cursor !== null);

  return { entries, problems };
}