CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

tessl-labs/factory

Skills that Tessl factory loops run: implement a Linear or GitHub issue as a pull request, shepherd that pull request through review and checks, wake the shepherd for pull requests that a push to their base branch left in conflict, and keep the work in step when a person stops, ends, answers or closes it.

Quality

88%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

list-conflicting-prs.mjsskills/merge-conflict-sweep/scripts/

#!/usr/bin/env node
// Lists the open pull requests against the branch a push just moved, narrows
// them to the factory's, waits until GitHub says which of those conflict, and
// reads every issue a conflicting one names: from Linear, each identifier its
// branch, title or description mentions, and from GitHub, the issue numbers
// its branch suffix and `Closes` lines name. Writes one fact sheet per
// conflicting factory pull request, the counts, and the pull requests it left
// alone, into the sweep's directory, and prints one summary line. Decides no
// issue and triggers nothing; `main` runs when the file is the entry point.

import {
  existsSync,
  mkdirSync,
  readFileSync,
  rmSync,
  writeFileSync,
} from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import {
  DEFAULT_DIR,
  NOT_REACHED_REASON,
  boundsFrom,
  flagValue,
  parseJson,
  parseUntil,
  readJson,
  realClock,
  runCommand,
  writeJson,
} from './sweep-common.mjs';

// The branch prefix, label and fixer Tessl factory pull requests use, for
// each input left unset.
export const DEFAULT_BRANCH_PREFIXES = 'tessl/';
export const DEFAULT_REQUIRED_LABEL = 'tessl';
export const DEFAULT_FIXER_ACTION = 'shepherd';
// The issue label the factory's pull requests name when they name none.
export const DEFAULT_ISSUE_LABEL = 'tessl';
// Why every Linear token is unreadable in a run with no Linear access.
export const NO_LINEAR_REASON =
  'this run has no Linear access: LINEAR_TOKEN or LINEAR_API_URL is unset';
// Linear's largest page; one batch read covers this many identifiers.
export const ISSUE_BATCH_SIZE = 50;
// How much of a description a fact sheet quotes: the lines that mention an
// identifier-shaped token, each cut to this length, at most this many. A
// `Closes` line is the issue's evidence, so it is kept whole and never dropped.
export const LINE_LENGTH = 300;
export const LINE_CAP = 40;

const HOUR = 3_600_000;

// `stateHistory(last: 1)` is the current state's span, open (`endedAt: null`).
const ISSUES_QUERY = `query MergeConflictSweepIssues($ids: [ID!]!, $first: Int!) {
  issues(first: $first, filter: { id: { in: $ids } }) {
    nodes {
      identifier
      state { name type }
      delegate { id }
      stateHistory(last: 1) { nodes { startedAt endedAt state { name } } }
    }
  }
}`;

/** A comma-separated input as a list, empty entries dropped. */
export function parseList(value) {
  return (value ?? '')
    .split(',')
    .map((item) => item.trim())
    .filter(Boolean);
}

/** Milliseconds a duration such as `90s`, `30m`, `24h` or `2d` names, or null for none. */
export function parseDuration(value, name = 'duration') {
  const text = (value ?? '').trim();
  if (text === '') return null;
  const match = /^([1-9][0-9]*)([smhd])$/.exec(text);
  if (!match) throw new Error(`${name} is not a duration such as 24h: ${text}`);
  const unit = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2]];
  return Number(match[1]) * unit;
}

/** The listing's configuration from the skill's inputs. */
export function readConfig(inputs) {
  const config = {
    authorIds: parseAuthorIds(inputs.GITHUB_AUTHOR_IDS),
    branchPrefixes: parseList(
      orDefault(inputs.BRANCH_PREFIXES, DEFAULT_BRANCH_PREFIXES),
    ),
    requiredLabel: orDefault(
      inputs.GITHUB_REQUIRED_LABEL,
      DEFAULT_REQUIRED_LABEL,
    ),
    skipLabel: (inputs.GITHUB_SKIP_LABEL ?? '').trim(),
    fixerAction: orDefault(inputs.FIXER_ACTION, DEFAULT_FIXER_ACTION),
    tesslAppLinearUserId: orDefault(inputs.LINEAR_TESSL_APP_USER_ID, null),
    linearActiveStates: parseList(inputs.LINEAR_ACTIVE_STATES),
    maxAgeMs: parseStateAges(inputs.LINEAR_STATE_MAX_AGES),
    githubIssueLabel: orDefault(inputs.GITHUB_ISSUE_LABEL, DEFAULT_ISSUE_LABEL),
    dryRun: String(inputs.DRY_RUN ?? 'false').trim() === 'true',
  };
  if (config.branchPrefixes.length === 0)
    throw new Error('BRANCH_PREFIXES names no prefix');
  return config;
}

/** An input's trimmed value, or `fallback` when it is absent or blank. */
function orDefault(value, fallback) {
  return (value ?? '').trim() || fallback;
}

/**
 * The age limits a `State=duration` list such as `In Review=24h,In
 * Progress=7d` names, as milliseconds by state name. A state it does not name has
 * no limit.
 */
export function parseStateAges(value) {
  const limits = {};
  for (const entry of parseList(value)) {
    const at = entry.lastIndexOf('=');
    const state = at === -1 ? '' : entry.slice(0, at).trim();
    if (!state)
      throw new Error(
        `LINEAR_STATE_MAX_AGES entry is not State=duration: ${entry}`,
      );
    if (Object.hasOwn(limits, state))
      throw new Error(`LINEAR_STATE_MAX_AGES names ${state} twice`);
    limits[state] = parseDuration(
      entry.slice(at + 1),
      `LINEAR_STATE_MAX_AGES ${state}`,
    );
    if (limits[state] === null)
      throw new Error(`LINEAR_STATE_MAX_AGES ${state} has no duration`);
  }
  return limits;
}

/**
 * The GitHub user ids in a comma-separated input. An id never changes, where
 * a login does when an app is renamed, so a login here is an error.
 */
export function parseAuthorIds(value) {
  const entries = parseList(value);
  if (entries.length === 0) throw new Error('GITHUB_AUTHOR_IDS is empty');
  for (const entry of entries)
    if (!/^[1-9][0-9]*$/.test(entry))
      throw new Error(
        `GITHUB_AUTHOR_IDS entry is not a GitHub user id: ${entry}`,
      );
  return entries.map(Number);
}

/**
 * Why a PR is not the factory's to sweep, from what GitHub alone shows, or null
 * when it is: checks that need only what GitHub shows.
 */
export function prSkipReason(pr, config, baseBranch) {
  if (pr.isCrossRepository) return 'from a fork';
  if (pr.baseRefName !== baseBranch)
    return `base is ${pr.baseRefName}, not ${baseBranch}`;
  const id = pr.author?.id ?? null;
  if (!config.authorIds.includes(id))
    return `author ${pr.author?.login || '(none)'} (id ${id ?? 'none'}) is not a factory author`;
  if (!branchPrefix(pr.headRefName, config))
    return `branch ${pr.headRefName} has no factory prefix`;
  const labels = (pr.labels ?? []).map((label) => label.name);
  if (!labels.includes(config.requiredLabel))
    return `no ${config.requiredLabel} label`;
  if (config.skipLabel && labels.includes(config.skipLabel))
    return `carries ${config.skipLabel}`;
  return null;
}

/** The factory prefix the branch starts with, or null. */
export function branchPrefix(branch, config) {
  return (
    config.branchPrefixes.find((prefix) => branch.startsWith(prefix)) ?? null
  );
}

/** Whether a listed mergeability is one GitHub has finished computing. */
export function isKnown(pr) {
  return pr.mergeable === 'CONFLICTING' || pr.mergeable === 'MERGEABLE';
}

/**
 * GitHub computes mergeability lazily after the base moves, and each later
 * push sends most PRs back to UNKNOWN for a while. So keep each PR's latest
 * known mergeability and head across every list, unless a later list shows a
 * new head, and while a PR `isWaited`
 * names has not been seen with a known value, list the open PRs again every
 * `pollMs`. A list starts only when it would end, at its timeout, by `end`; a
 * failed list is tried again at the next poll. Returns the PRs of the first
 * list still open in the latest, each as last seen with a known value or as
 * first listed, and the time of the latest list that succeeded.
 */
export async function waitForMergeability(
  listed,
  isWaited,
  relist,
  { clock, end, callMs, pollMs },
) {
  const latest = new Map(
    listed.prs.filter(isKnown).map((pr) => [pr.number, pr]),
  );
  let open = new Set(listed.prs.map((pr) => pr.number));
  let listedAt = listed.listedAt;
  const waited = listed.prs.filter(isWaited);
  while (
    waited.some((pr) => open.has(pr.number) && !latest.has(pr.number)) &&
    clock.now() + pollMs + callMs <= end
  ) {
    await clock.sleep(pollMs);
    const read = relist();
    if (!read.ok) continue;
    listedAt = clock.now();
    for (const pr of read.prs) {
      if (isKnown(pr)) latest.set(pr.number, pr);
      // A new head makes the value known for the old one stale.
      else if (latest.get(pr.number)?.headRefOid !== pr.headRefOid)
        latest.delete(pr.number);
    }
    // A PR closed since the first list has nothing left to wait for.
    open = new Set(read.prs.map((pr) => pr.number));
  }
  return {
    prs: listed.prs
      .filter((pr) => open.has(pr.number))
      .map((pr) => latest.get(pr.number) ?? pr),
    listedAt,
  };
}

const TOKEN = /(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]*-[0-9]+(?![A-Za-z0-9])/g;

/** Every identifier-shaped token in the text, upper-cased, in order, once each. */
export function identifierTokens(text) {
  return [
    ...new Set(
      [...(text ?? '').matchAll(TOKEN)].map((match) => match[0].toUpperCase()),
    ),
  ];
}

const CLOSES = /(?<![A-Za-z0-9])closes(?![A-Za-z0-9])/i;

/**
 * The description lines that say `Closes` or mention an identifier-shaped
 * token, each cut to `LINE_LENGTH`. Past `LINE_CAP` lines it keeps the first
 * and last halves. A line that says `Closes` is never cut or dropped.
 */
export function mentioningLines(body) {
  const lines = (body ?? '')
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter((line) => CLOSES.test(line) || identifierTokens(line).length > 0);
  const half = LINE_CAP / 2;
  const kept = lines
    .filter(
      (line, i) =>
        lines.length <= LINE_CAP ||
        i < half ||
        i >= lines.length - half ||
        CLOSES.test(line),
    )
    .map((line) =>
      line.length > LINE_LENGTH && !CLOSES.test(line)
        ? `${line.slice(0, LINE_LENGTH)}…`
        : line,
    );
  return { lines: kept, omitted: lines.length - kept.length };
}

/** One issue's facts from a batch read's node, or null when the node is incomplete. */
export function issueFacts(node, collectedAt) {
  if (typeof node?.state?.name !== 'string') return null;
  const span = node.stateHistory?.nodes?.at(-1);
  const open =
    span && span.endedAt == null && span.state?.name === node.state.name;
  const startedAt = open ? Date.parse(span.startedAt) : NaN;
  const entered = Number.isNaN(startedAt) ? null : startedAt;
  return {
    tracker: 'linear',
    state: node.state.name,
    stateType: node.state.type ?? null,
    delegateId: node.delegate?.id ?? null,
    stateEnteredAt: entered === null ? null : new Date(entered).toISOString(),
    hoursInState:
      entered === null
        ? null
        : Math.round(((collectedAt - entered) / HOUR) * 10) / 10,
  };
}

// Linear refuses a whole read when one id is not an identifier it could
// issue: a key over seven characters, or a number with a leading zero or
// past 32 bits. Such a token names no issue, so it is never sent.
const MAX_ISSUE_NUMBER = 2 ** 31 - 1;

/** Whether Linear could have issued the identifier, an upper-cased token. */
export function isIssueIdentifier(token) {
  const match = /^([A-Z][A-Z0-9]{0,6})-([1-9][0-9]*|0)$/.exec(token);
  return match !== null && Number(match[2]) <= MAX_ISSUE_NUMBER;
}

/**
 * Reads the identifiers in batches through `linear`, which retries. Each
 * identifier maps to its facts, `{ unknown: true }` when Linear does not know
 * it or could not have issued it, or `{ unreadable: reason }` when its batch
 * failed or was not reached.
 */
export async function readIssues(linear, tokens, now) {
  const issues = new Map();
  for (const token of tokens)
    if (!isIssueIdentifier(token)) issues.set(token, { unknown: true });
  const identifiers = tokens.filter(isIssueIdentifier);
  for (let start = 0; start < identifiers.length; start += ISSUE_BATCH_SIZE) {
    const batch = identifiers.slice(start, start + ISSUE_BATCH_SIZE);
    const read = await linear(ISSUES_QUERY, {
      ids: batch,
      first: ISSUE_BATCH_SIZE,
    });
    const nodes = read.ok ? read.data?.issues?.nodes : null;
    if (!Array.isArray(nodes)) {
      const reason = read.ok ? 'issue read was incomplete' : read.reason;
      for (const id of batch) issues.set(id, { unreadable: reason });
      continue;
    }
    const collectedAt = now();
    const byId = new Map(
      nodes.map((node) => [String(node?.identifier).toUpperCase(), node]),
    );
    for (const id of batch) {
      const node = byId.get(id);
      if (!node) {
        issues.set(id, { unknown: true });
        continue;
      }
      issues.set(
        id,
        issueFacts(node, collectedAt) ?? {
          unreadable: 'issue read was incomplete',
        },
      );
    }
  }
  return issues;
}

const ISSUE_SUFFIX = /^issue-([1-9][0-9]*)$/i;
// `#12`, but not `other/repo#12`, which names an issue in another repository.
const ISSUE_REF = /(?<![A-Za-z0-9_./-])#([1-9][0-9]*)(?![0-9])/g;
// A line that begins with `Closes`, after any markup such as `- **`.
const CLOSES_LINE = /^[^A-Za-z0-9]*closes(?![A-Za-z0-9])/i;

/**
 * The GitHub issues a PR names, each as `#<number>`, in order, once each: the
 * number in a branch suffix such as `issue-12`, and every `#<number>` on a
 * description line that begins with `Closes`.
 */
export function githubIssueRefs(pr, config) {
  const prefix = branchPrefix(pr.headRefName, config) ?? '';
  const refs = [];
  const branch = ISSUE_SUFFIX.exec(pr.headRefName.slice(prefix.length));
  if (branch) refs.push(`#${branch[1]}`);
  for (const line of (pr.body ?? '').split(/\r?\n/))
    if (CLOSES_LINE.test(line.trim()))
      for (const match of line.matchAll(ISSUE_REF)) refs.push(`#${match[1]}`);
  return [...new Set(refs)];
}

/**
 * One GitHub issue's facts from the response `gh api graphql` printed, which
 * it prints even when it exits non-zero: `{ unknown: true }` when the number
 * is a pull request or names nothing, or null when the response is not a
 * whole answer, such as an issue without its labels.
 */
export function githubIssueFacts(response) {
  const repository = response?.data?.repository;
  const node = repository?.issueOrPullRequest;
  if (
    node === null &&
    (response.errors ?? []).some((error) => error?.type === 'NOT_FOUND')
  )
    return { unknown: true };
  if (node?.__typename === 'PullRequest') return { unknown: true };
  if (node?.__typename !== 'Issue' || typeof node.state !== 'string')
    return null;
  // A partial answer can leave the labels out; that is no answer, not an issue
  // without labels.
  const labels = node.labels?.nodes;
  if (
    !Array.isArray(labels) ||
    !labels.every((label) => typeof label?.name === 'string')
  )
    return null;
  return {
    tracker: 'github',
    state: node.state,
    labels: labels.map((label) => label.name),
  };
}

/**
 * Reads each `#<number>` through `github`, which retries. Each maps to its
 * facts, or `{ unreadable: reason }` when its read failed or was not reached.
 */
export async function readGithubIssues(github, refs) {
  const issues = new Map();
  for (const ref of refs) {
    const read = await github(Number(ref.slice(1)));
    issues.set(ref, read.ok ? read.facts : { unreadable: read.reason });
  }
  return issues;
}

/** The age limits in hours for the fact sheets, by state name. */
function limitHours(config) {
  return Object.fromEntries(
    Object.entries(config.maxAgeMs).map(([state, ms]) => [state, ms / HOUR]),
  );
}

/**
 * The tokens a fact sheet's Linear issues are read for: the branch's, the
 * title's and the description's. A branch whose suffix is `issue-<number>`
 * names a GitHub issue, so it gives no token.
 */
function prTokens(pr, config) {
  const prefix = branchPrefix(pr.headRefName, config) ?? '';
  const githubBranch = ISSUE_SUFFIX.test(pr.headRefName.slice(prefix.length));
  return identifierTokens(
    `${githubBranch ? '' : pr.headRefName}\n${pr.title}\n${pr.body}`,
  );
}

const ISSUE_LABEL_LINE = /<!--\s*tessl-factory:issue-label:\s*(.*?)\s*-->/;

/** The issue label a PR's description names in its `tessl-factory:issue-label` line, or null. */
export function issueLabelFromDescription(body) {
  return ISSUE_LABEL_LINE.exec(body ?? '')?.[1] || null;
}

/** The fact sheet for one conflicting factory PR. */
export function factSheet(pr, issues, config, collectedAt) {
  const prefix = branchPrefix(pr.headRefName, config);
  const { lines, omitted } = mentioningLines(pr.body);
  const named = [...prTokens(pr, config), ...githubIssueRefs(pr, config)];
  return {
    number: pr.number,
    url: pr.url,
    headSha: pr.headRefOid,
    branch: pr.headRefName,
    branchSuffix: pr.headRefName.slice(prefix.length),
    title: pr.title ?? '',
    descriptionLines: lines,
    descriptionLinesOmitted: omitted,
    issues: Object.fromEntries(named.map((id) => [id, issues.get(id)])),
    collectedAt: new Date(collectedAt).toISOString(),
    linearActiveStates:
      config.linearActiveStates.length > 0 ? config.linearActiveStates : null,
    ageLimitHours: limitHours(config),
    tesslAppLinearUserId: config.tesslAppLinearUserId,
    githubIssueLabel: config.githubIssueLabel,
    issueLabelFromDescription: issueLabelFromDescription(pr.body),
  };
}

/** A retried read that must succeed, or an error naming why it failed. */
async function mustRead(read, policy, time) {
  const result = await retry(read, policy, time);
  if (!result.ok) throw new Error(result.reason);
  return result;
}

/**
 * Calls `attempt` until it returns `{ ok: true }`, waiting between tries with
 * delays that double from `firstDelayMs` to `maxDelayMs`. A try starts only
 * when a call running to its timeout would end inside `forMs` and the
 * deadline. Returns the last failure once no further try fits, or a
 * `notReached` failure when not even the first one did.
 */
export async function retry(attempt, policy, { clock, deadline, callMs }) {
  if (clock.now() + callMs > deadline)
    return { ok: false, notReached: true, reason: NOT_REACHED_REASON };
  const end = Math.min(clock.now() + policy.forMs, deadline);
  let delay = policy.firstDelayMs;
  for (;;) {
    const result = attempt();
    if (result.ok) return result;
    if (clock.now() + delay + callMs > end) return result;
    await clock.sleep(delay);
    delay = Math.min(delay * 2, policy.maxDelayMs);
  }
}

/**
 * The whole listing, from its configuration and push event to what it writes,
 * with every call made through `io` and every wait through `clock`. Nothing
 * runs past `until`: the mergeability wait ends `issueReadsMs` before it, so
 * the issue reads have time, and a read that could not start by it is
 * reported as not reached.
 */
export async function list({ config, event, io, clock, until, bounds }) {
  const callMs = bounds.callTimeoutMs;
  const reads = { clock, callMs, deadline: until };

  let { baseBranch, repositoryId } = event;
  if (baseBranch === undefined || repositoryId === undefined) {
    const repository = await mustRead(
      () => io.repository(),
      bounds.readRetry,
      reads,
    );
    baseBranch ??= repository.defaultBranch;
    repositoryId ??= repository.id;
  }
  const first = await mustRead(
    () => io.listOpenPrs(baseBranch),
    bounds.readRetry,
    reads,
  );
  const isFactory = (pr) => !prSkipReason(pr, config, baseBranch);
  const settled = await waitForMergeability(
    { prs: first.prs, listedAt: clock.now() },
    isFactory,
    () => io.listOpenPrs(baseBranch),
    {
      clock,
      callMs,
      pollMs: bounds.mergeablePollMs,
      end: until - bounds.issueReadsMs,
    },
  );

  const skipped = [];
  const conflicting = [];
  const counts = {
    openPullRequests: settled.prs.length,
    notFactory: 0,
    noConflict: 0,
    mergeabilityUnknown: 0,
    conflicting: 0,
  };
  for (const pr of settled.prs) {
    const reason = prSkipReason(pr, config, baseBranch);
    if (reason) {
      counts.notFactory += 1;
      skipped.push({ number: pr.number, reason });
    } else if (pr.mergeable === 'MERGEABLE') {
      counts.noConflict += 1;
      skipped.push({ number: pr.number, reason: 'no conflict' });
    } else if (pr.mergeable !== 'CONFLICTING') {
      counts.mergeabilityUnknown += 1;
      skipped.push({
        number: pr.number,
        reason: `mergeability still ${pr.mergeable || 'unknown'} at the listing's end time; the next sweep takes it up`,
      });
    } else {
      counts.conflicting += 1;
      conflicting.push(pr);
    }
  }

  let linearRequests = 0;
  const linear = (query, variables) =>
    retry(
      () => {
        linearRequests += 1;
        return io.linear(query, variables);
      },
      bounds.readRetry,
      reads,
    );
  const identifiers = [
    ...new Set(conflicting.flatMap((pr) => prTokens(pr, config))),
  ];
  const issues = io.linearConfigured()
    ? await readIssues(linear, identifiers, () => clock.now())
    : new Map(identifiers.map((id) => [id, { unreadable: NO_LINEAR_REASON }]));

  let githubIssueRequests = 0;
  const github = (number) =>
    retry(
      () => {
        githubIssueRequests += 1;
        return io.githubIssue(number);
      },
      bounds.readRetry,
      reads,
    );
  const refs = [
    ...new Set(conflicting.flatMap((pr) => githubIssueRefs(pr, config))),
  ];
  for (const [ref, facts] of await readGithubIssues(github, refs))
    issues.set(ref, facts);

  const collectedAt = clock.now();
  return {
    listing: {
      collectedAt: new Date(collectedAt).toISOString(),
      dryRun: config.dryRun,
      fixerAction: config.fixerAction,
      baseBranch,
      repositoryId,
      counts,
      linearRequests,
      githubIssueRequests,
      factSheets: conflicting.map((pr) => pr.number),
    },
    skipped,
    factSheets: conflicting.map((pr) =>
      factSheet(pr, issues, config, collectedAt),
    ),
  };
}

/** The pushed branch and repository id from the run's trigger events, when there are any. */
export function readPushEvent(events) {
  const push = [...(events ?? [])]
    .reverse()
    .find((entry) => entry?.event === 'github:push');
  if (!push) return {};
  const ref = push.payload?.ref;
  const id = push.payload?.repository?.id;
  return {
    ...(typeof ref === 'string' && ref.startsWith('refs/heads/')
      ? { baseBranch: ref.slice('refs/heads/'.length) }
      : {}),
    ...(Number.isInteger(id) ? { repositoryId: id } : {}),
  };
}

// `gh pr list` prints no author id, so the listing asks GraphQL, every page.
// `author` is an `Actor`; only a `Bot` or a `User` has a `databaseId`.
const PRS_QUERY = `query MergeConflictSweepPrs($owner: String!, $repo: String!, $base: String!, $endCursor: String) {
  repository(owner: $owner, name: $repo) {
    pullRequests(states: OPEN, baseRefName: $base, first: 100, after: $endCursor) {
      nodes {
        number
        url
        author { login ... on Bot { databaseId } ... on User { databaseId } }
        baseRefName
        headRefName
        headRefOid
        isCrossRepository
        labels(first: 100) { nodes { name } }
        title
        body
        mergeable
      }
      pageInfo { hasNextPage endCursor }
    }
  }
}`;

/**
 * The open PRs from the pages `gh api graphql --paginate --slurp` printed,
 * each with its author's login and numeric id and its labels as a list, or
 * null when a page is not a pull request list.
 */
export function prsFromPages(pages) {
  if (!Array.isArray(pages)) return null;
  const prs = [];
  for (const page of pages) {
    const nodes = page?.data?.repository?.pullRequests?.nodes;
    if (!Array.isArray(nodes)) return null;
    for (const node of nodes)
      prs.push({
        ...node,
        author: node.author
          ? {
              login: node.author.login,
              id: Number.isInteger(node.author.databaseId)
                ? node.author.databaseId
                : null,
            }
          : null,
        labels: node.labels?.nodes ?? [],
      });
  }
  return prs;
}

// `issueOrPullRequest` answers for a pull request's number too, by its type.
const GITHUB_ISSUE_QUERY = `query MergeConflictSweepIssue($owner: String!, $repo: String!, $number: Int!) {
  repository(owner: $owner, name: $repo) {
    issueOrPullRequest(number: $number) {
      __typename
      ... on Issue { state labels(first: 100) { nodes { name } } }
    }
  }
}`;

/** The GraphQL endpoint under a Linear API base URL, with or without its `/graphql`. */
export function linearGraphqlUrl(base) {
  const url = base.replace(/\/+$/, '');
  return url.endsWith('/graphql') ? url : `${url}/graphql`;
}

/**
 * The listing's calls to `gh` and Linear, each one command run to its
 * timeout. Each returns `{ ok: true, … }` or `{ ok: false, reason }`.
 */
export function commandIo({ bounds, workDir, env = process.env }) {
  const run = (command, args) =>
    runCommand(command, args, { timeoutMs: bounds.callTimeoutMs });
  let linearAttempt = 0;
  return {
    /** Whether the run has what the Linear transport needs. */
    linearConfigured() {
      return Boolean(env.LINEAR_TOKEN && env.LINEAR_API_URL);
    },
    /** One GitHub issue in this repository, by number. */
    githubIssue(number) {
      const result = run('gh', [
        'api',
        'graphql',
        '-F',
        'owner={owner}',
        '-F',
        'repo={repo}',
        '-F',
        `number=${number}`,
        '-f',
        `query=${GITHUB_ISSUE_QUERY}`,
      ]);
      const facts = githubIssueFacts(parseJson(result.stdout ?? ''));
      if (facts) return { ok: true, facts };
      return {
        ok: false,
        reason: result.ok
          ? 'issue read was incomplete'
          : `could not read issue #${number}: ${result.reason}`,
      };
    },
    /** The repository's numeric REST id and default branch, in one call. */
    repository() {
      const result = run('gh', [
        'api',
        'repos/{owner}/{repo}',
        '--jq',
        '{id: .id, defaultBranch: .default_branch}',
      ]);
      if (!result.ok)
        return {
          ok: false,
          reason: `could not read the repository: ${result.reason}`,
        };
      const repository = parseJson(result.stdout);
      if (!Number.isInteger(repository?.id) || !repository.defaultBranch)
        return {
          ok: false,
          reason: 'could not read the repository: gh printed no id',
        };
      return { ok: true, ...repository };
    },
    listOpenPrs(baseBranch) {
      const result = run('gh', [
        'api',
        'graphql',
        '--paginate',
        '--slurp',
        '-F',
        'owner={owner}',
        '-F',
        'repo={repo}',
        '-f',
        `base=${baseBranch}`,
        '-f',
        `query=${PRS_QUERY}`,
      ]);
      if (!result.ok)
        return {
          ok: false,
          reason: `could not list open pull requests: ${result.reason}`,
        };
      const prs = prsFromPages(parseJson(result.stdout));
      if (!prs)
        return {
          ok: false,
          reason: 'could not list open pull requests: gh printed no list',
        };
      return { ok: true, prs };
    },
    /**
     * One Linear request to `$LINEAR_API_URL/graphql`. Each attempt has its
     * own files, so a killed attempt's orphan cannot write into a later one's,
     * and the token goes to `curl` in a header file, never on its command line.
     */
    linear(query, variables) {
      linearAttempt += 1;
      const requestFile = join(workDir, `${linearAttempt}.request.json`);
      const responseFile = join(workDir, `${linearAttempt}.response.json`);
      const headersFile = join(workDir, `${linearAttempt}.headers`);
      writeJson(requestFile, { query, variables });
      writeFileSync(
        headersFile,
        `Authorization: Bearer ${env.LINEAR_TOKEN}\n`,
        {
          mode: 0o600,
        },
      );
      const result = run('curl', [
        '--silent',
        '--show-error',
        '--connect-timeout',
        '5',
        '--max-time',
        '30',
        '--request',
        'POST',
        '--header',
        `@${headersFile}`,
        '--header',
        'Content-Type: application/json',
        '--data-binary',
        `@${requestFile}`,
        '--output',
        responseFile,
        '--write-out',
        '%{http_code}',
        linearGraphqlUrl(env.LINEAR_API_URL),
      ]);
      rmSync(headersFile, { force: true });
      if (!result.ok)
        return {
          ok: false,
          reason: `the Linear request did not complete: ${result.reason}`,
        };
      const status = result.stdout.trim();
      if (!/^2[0-9][0-9]$/.test(status))
        return { ok: false, reason: `Linear answered HTTP ${status}` };
      const response = existsSync(responseFile)
        ? parseJson(readFileSync(responseFile, 'utf8'))
        : null;
      if ((response?.errors ?? []).length > 0)
        return { ok: false, reason: 'the Linear response contains errors' };
      if (!response?.data)
        return { ok: false, reason: 'Linear printed no data' };
      return { ok: true, data: response.data };
    },
  };
}

/**
 * Removes what an earlier listing wrote into `dir`, and the decisions made
 * from it, so a listing that fails leaves nothing to trigger from.
 */
export function clearListing(dir) {
  for (const name of [
    'listing.json',
    'listing-skipped.json',
    'facts',
    'decisions',
  ])
    rmSync(join(dir, name), { recursive: true, force: true });
}

/**
 * Writes the listing into `dir`: `listing-skipped.json` with each PR it left
 * alone, `facts/<number>.json` per conflicting factory PR, an empty
 * `decisions/`, and last `listing.json` with the counts and the fact sheets'
 * numbers.
 */
export function writeListing(dir, { listing, skipped, factSheets }) {
  clearListing(dir);
  for (const sub of ['facts', 'decisions'])
    mkdirSync(join(dir, sub), { recursive: true });
  for (const sheet of factSheets)
    writeJson(join(dir, 'facts', `${sheet.number}.json`), sheet);
  writeJson(join(dir, 'listing-skipped.json'), skipped);
  writeJson(join(dir, 'listing.json'), listing);
}

async function main() {
  const until = parseUntil(flagValue('--until'));
  const bounds = boundsFrom(process.env.MERGE_CONFLICT_SWEEP_BOUNDS);
  const root = runCommand('git', ['rev-parse', '--show-toplevel']);
  if (!root.ok) throw new Error(`not in a clone: ${root.reason}`);
  const launch = join(root.stdout.trim(), '.cloud-launch');
  const requestedInputs = flagValue('--inputs');
  const inputsPath = join(launch, basename(requestedInputs ?? 'inputs.json'));
  const inputs = readJson(inputsPath) ?? {};
  const config = readConfig({
    ...inputs,
    ...(process.argv.includes('--dry-run') ? { DRY_RUN: 'true' } : {}),
  });
  const event = readPushEvent(
    readJson(flagValue('--events') ?? join(launch, 'trigger-events.json')),
  );
  const dir = DEFAULT_DIR;
  clearListing(dir);
  const workDir = join(dir, 'linear');
  mkdirSync(workDir, { recursive: true });
  try {
    const result = await list({
      config,
      event,
      io: commandIo({ bounds, workDir }),
      clock: realClock,
      until,
      bounds,
    });
    writeListing(dir, result);
    const { counts } = result.listing;
    process.stdout.write(
      `listed: ${counts.openPullRequests} open pull requests, ${counts.conflicting} conflicting factory pull requests with fact sheets in ${join(dir, 'facts')} for decisions in ${join(dir, 'decisions')}, ${counts.noConflict} without a conflict, ${counts.mergeabilityUnknown} with mergeability still unknown, ${counts.notFactory} not the factory's. Wrote ${join(dir, 'listing.json')}.\n`,
    );
  } finally {
    rmSync(workDir, { recursive: true, force: true });
  }
}

if (
  process.argv[1] &&
  resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
  main().then(
    () => process.exit(0),
    (error) => {
      process.stdout.write(`failed: ${error.message}\n`);
      process.exit(2);
    },
  );
}

README.md

tile.json