CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

tessl/factory-setup

Set up, understand, and change a Tessl factory on a repository: pick automations that fit how you work, such as shipping tickets as pull requests, answering reviews and fixing checks on those pull requests, code review, and scheduled jobs; prepare the repository for agents; write issues for the factory to build; then see what the factory has done, find out why a run failed or never started, and change or extend it.

Quality

91%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Medium

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

github.mjsskills/prepare-repo/scripts/sources/

// GitHub sources: review feedback on merged pull requests, bug-fix and revert
// pull requests, and CI failures on pull requests. All read through `gh`.

import { FIX, REVERT } from '../lib/patterns.mjs';
import { clip, failed, mapLimit, run, runJson, skipped } from '../lib/run.mjs';

const FETCH_CONCURRENCY = 6;
const BODY_CHARS = 1200;
const LISTED_PR_FACTOR = 4;
const FAILED_RUN_DETAILS = 15;
const PAGE_SIZE = 100;

const BUG_LABEL = /^(bug|regression|incident|hotfix)$/i;

/** A GitHub user is a bot when its type says so or its login carries `[bot]`. */
export function isBot(user) {
  if (!user) return false;
  if (user.type === 'Bot' || user.is_bot === true) return true;
  return /\[bot\]$/i.test(user.login ?? '');
}

/** `revert`, `fix`, or null for a pull request's title and labels. */
export function classifyPr(pr) {
  const title = pr.title ?? '';
  if (REVERT.test(title)) return 'revert';
  const labels = (pr.labels ?? []).map((l) => l.name ?? '');
  if (FIX.test(title) || labels.some((l) => BUG_LABEL.test(l))) return 'fix';
  return null;
}

/**
 * Check that `gh` is installed and can reach the API, and resolve the
 * repository and its host. Resolves to `{ ok, repo, host, reason }`.
 */
export async function githubPreflight(root, repoFlag) {
  // `rate_limit` answers any valid token; `user` refuses GitHub App tokens.
  const probe = await run(
    'gh',
    ['api', 'rate_limit', '--jq', '.rate.remaining'],
    {
      cwd: root,
    },
  );
  if (probe.missing) return { ok: false, reason: 'gh is not installed' };
  if (!probe.ok)
    return { ok: false, reason: `gh is not authenticated: ${probe.error}` };
  const args = ['repo', 'view', '--json', 'nameWithOwner,url'];
  if (repoFlag) args.splice(2, 0, repoFlag);
  // An unreachable repository makes search return nothing, which would read as
  // a quiet window, so resolving it is part of the preflight.
  const view = await runJson('gh', args, { cwd: root });
  if (!view.ok)
    return {
      ok: false,
      reason: `could not resolve the GitHub repository: ${view.error}`,
    };
  // Later calls name the host, so a GitHub Enterprise remote is not read
  // from github.com.
  return {
    ok: true,
    repo: view.data.nameWithOwner,
    host: new URL(view.data.url).host,
  };
}

/** `HOST/OWNER/NAME`, the `--repo` form that keeps the host. */
function repoArg(ctx) {
  return `${ctx.host}/${ctx.repo}`;
}

/** `gh api` arguments for `path` on the repository's host. */
function apiArgs(ctx, path) {
  return ['api', '--hostname', ctx.host, path];
}

/** List merged pull requests in the window, newest first, up to `limit`. */
async function listMergedPrs(ctx, limit) {
  return runJson(
    'gh',
    [
      'pr',
      'list',
      '--repo',
      repoArg(ctx),
      '--state',
      'merged',
      '--search',
      `merged:>=${ctx.since} sort:updated-desc`,
      '--limit',
      String(limit),
      '--json',
      'number,title,url,author,mergedAt,labels',
    ],
    { cwd: ctx.root },
  );
}

function feedbackItem(pr, kind, raw) {
  return {
    pr: pr.number,
    kind,
    author: raw.user?.login ?? null,
    bot: isBot(raw.user),
    ...(raw.state ? { state: raw.state } : {}),
    ...(raw.path ? { path: raw.path } : {}),
    ...((raw.line ?? raw.original_line)
      ? { line: raw.line ?? raw.original_line }
      : {}),
    body: clip(raw.body, BODY_CHARS),
    url: raw.html_url ?? null,
  };
}

/**
 * Fetch one pull request's inline comments, review bodies and conversation,
 * page by page. People and bots have separate caps, `perPr` and `botsPerPr`,
 * so a bot that comments on every push cannot crowd out what a reviewer said
 * on the same pull request. Reading stops once both caps are over, so a cut is
 * known to have dropped something, or once `maxPages` pages are read. The page
 * bound holds however many records have blank bodies, which are read but not
 * kept. Inline comments come first: they carry the file and line.
 */
async function fetchPrFeedback(ctx, pr, perPr, botsPerPr, maxPages) {
  const base = `repos/${ctx.repo}`;
  const endpoints = [
    ['inline', `${base}/pulls/${pr.number}/comments`],
    ['review', `${base}/pulls/${pr.number}/reviews`],
    ['conversation', `${base}/issues/${pr.number}/comments`],
  ];
  const people = [];
  const bots = [];
  const errors = [];
  let pages = 0;
  let pageCut = false;
  const full = () => people.length > perPr && bots.length > botsPerPr;
  endpoints: for (const [kind, path] of endpoints) {
    for (let page = 1; !full(); page++) {
      if (pages >= maxPages) {
        pageCut = true;
        break endpoints;
      }
      pages++;
      const result = await runJson(
        'gh',
        apiArgs(ctx, `${path}?per_page=${PAGE_SIZE}&page=${page}`),
        { cwd: ctx.root },
      );
      if (!result.ok || !Array.isArray(result.data)) {
        errors.push(`${kind}: ${result.error ?? 'unexpected response'}`);
        break;
      }
      for (const raw of result.data) {
        if (!String(raw.body ?? '').trim()) continue;
        const item = feedbackItem(pr, kind, raw);
        (item.bot ? bots : people).push(item);
      }
      if (result.data.length < PAGE_SIZE) break;
    }
    if (full()) break;
  }
  // Bots come back uncapped: which of them are status bots is only known
  // once every pull request is read, and their comments must not spend the
  // budget kept for review bots. `readFeedback` applies the bot caps.
  return {
    people: people.slice(0, perPr),
    bots,
    dropped: Math.max(0, people.length - perPr),
    pageCut,
    errors,
  };
}

/**
 * The bots that only ever post to the conversation, never on a file or as a
 * review: deploy previews, coverage reports, bundle sizes, quality gates. They
 * report on the pull request rather than review it, so their comments are not
 * feedback and are dropped, with a count per bot so the drop is visible.
 */
export function statusBots(items) {
  const kinds = new Map();
  for (const item of items.filter((i) => i.bot)) {
    const entry = kinds.get(item.author) ?? { comments: 0, reviewed: false };
    entry.comments++;
    if (item.kind !== 'conversation') entry.reviewed = true;
    kinds.set(item.author, entry);
  }
  return [...kinds.entries()]
    .filter(([, e]) => !e.reviewed)
    .map(([author, e]) => ({ author, comments: e.comments }))
    .sort((a, b) => b.comments - a.comments);
}

/**
 * Whether a window's feedback from people is too thin to build rules from:
 * fewer than `minPeople` entries, or fewer than `minPrs` pull requests with
 * any. The collector then reads a wider window once.
 */
export function isThin(summary, { minPeople, minPrs }) {
  return summary.people < minPeople || summary.prsWithPeople < minPrs;
}

/** Read the feedback of up to `maxPrs` of `prs`, under the total budgets. */
async function readFeedback(ctx, prs) {
  const { limits } = ctx;
  const sample = prs.slice(0, limits.maxPrs);
  // Stop starting new pull requests once the people budget is in hand, so
  // later ones are not fetched only to be cut. Bots never stop the reading.
  let held = 0;
  const fetched = await mapLimit(sample, FETCH_CONCURRENCY, async (pr) => {
    if (held >= limits.maxComments) return null;
    const f = await fetchPrFeedback(
      ctx,
      pr,
      limits.maxCommentsPerPr,
      limits.maxBotCommentsPerPr,
      limits.maxPagesPerPr,
    );
    held += f.people.length;
    return f;
  });
  // Status bots are found across every pull request read, before any bot
  // cap, so their comments never fill the review-bot budget.
  const allBots = fetched.flatMap((f) => f?.bots ?? []);
  const status = statusBots(allBots);
  const statusAuthors = new Set(status.map((s) => s.author));
  const items = [];
  const used = [];
  let cut = false;
  let botsKept = 0;
  sample.forEach((pr, i) => {
    const f = fetched[i];
    const room = limits.maxComments - items.filter((x) => !x.bot).length;
    if (!f || room <= 0) {
      cut = true;
      return;
    }
    if (f.people.length > room) cut = true;
    items.push(...f.people.slice(0, room));
    const reviewBots = f.bots.filter((b) => !statusAuthors.has(b.author));
    if (reviewBots.length > limits.maxBotCommentsPerPr) cut = true;
    const perPr = reviewBots.slice(0, limits.maxBotCommentsPerPr);
    const botRoom = Math.max(0, limits.maxBotComments - botsKept);
    if (perPr.length > botRoom) cut = true;
    items.push(...perPr.slice(0, botRoom));
    botsKept += Math.min(perPr.length, botRoom);
    used.push({ pr, f });
  });
  const people = items.filter((i) => !i.bot);
  const summary = {
    people: people.length,
    prsWithPeople: new Set(people.map((i) => i.pr)).size,
    reviewBots: items.length - people.length,
    statusBotComments: allBots.filter((b) => statusAuthors.has(b.author))
      .length,
  };
  return {
    sample,
    used,
    items,
    statusBots: status,
    summary,
    truncated:
      prs.length > sample.length ||
      cut ||
      used.some(({ f }) => f.dropped > 0 || f.pageCut),
  };
}

const WIDEN_FACTOR = 2;

/**
 * Review feedback on merged pull requests, plus the fix and revert pull
 * requests among a wider listing of the same window. When the window's
 * feedback from people is thin, the feedback is read again once over a window
 * twice as long with twice as many pull requests, and `widened` records it;
 * the fix and revert list keeps the original window.
 */
export async function collectPullRequests(ctx) {
  const limit = ctx.limits.maxPrs * LISTED_PR_FACTOR;
  const listed = await listMergedPrs(ctx, limit);
  if (!listed.ok) {
    const reason = `gh pr list failed: ${listed.error}`;
    return { reviewFeedback: failed(reason), bugFixPrs: failed(reason) };
  }
  const prs = listed.data;
  const bugFixPrs = {
    status: 'ok',
    listed: prs.length,
    truncated: prs.length >= limit,
    items: prs
      .map((pr) => ({ pr, kind: classifyPr(pr) }))
      .filter((x) => x.kind)
      .map(({ pr, kind }) => ({
        number: pr.number,
        kind,
        title: pr.title,
        author: pr.author?.login ?? null,
        mergedAt: pr.mergedAt,
        url: pr.url,
      })),
  };

  let read = await readFeedback(ctx, prs);
  let widened = null;
  const thin = { minPeople: ctx.limits.minPeople, minPrs: ctx.limits.minPrs };
  if (isThin(read.summary, thin) && !read.truncated && ctx.widenTo) {
    const wide = {
      ...ctx,
      since: ctx.widenTo.since,
      limits: {
        ...ctx.limits,
        maxPrs: ctx.limits.maxPrs * WIDEN_FACTOR,
        maxComments: ctx.limits.maxComments * WIDEN_FACTOR,
        maxBotComments: ctx.limits.maxBotComments * WIDEN_FACTOR,
      },
    };
    const wider = await listMergedPrs(
      wide,
      wide.limits.maxPrs * LISTED_PR_FACTOR,
    );
    if (wider.ok && wider.data.length > prs.length) {
      read = await readFeedback(wide, wider.data);
      widened = { days: ctx.widenTo.days, maxPrs: wide.limits.maxPrs };
    }
  }
  const { used, items } = read;
  const failedPrs = used
    .map(({ pr, f }) => ({ number: pr.number, errors: f.errors }))
    .filter((f) => f.errors.length > 0);
  // Every pull request failing is a broken source, not a quiet window.
  const reviewFeedback =
    used.length > 0 && failedPrs.length === used.length && items.length === 0
      ? failed(
          `every pull request's feedback fetch failed, first: ${failedPrs[0].errors[0]}`,
        )
      : {
          status: 'ok',
          prsRead: used.length,
          truncated: read.truncated,
          ...(widened ? { widened } : {}),
          thin: isThin(read.summary, thin),
          summary: read.summary,
          statusBots: read.statusBots,
          failedPrs,
          prs: used.map(({ pr }) => ({
            number: pr.number,
            title: pr.title,
            author: pr.author?.login ?? null,
            mergedAt: pr.mergedAt,
            url: pr.url,
          })),
          items,
        };
  return { reviewFeedback, bugFixPrs };
}

/** Group failed runs by workflow, most failures first. */
export function summarizeFailures(runs) {
  const byWorkflow = new Map();
  for (const r of runs) {
    const name = r.workflowName || r.name || '(unnamed)';
    const entry = byWorkflow.get(name) ?? {
      workflow: name,
      failures: 0,
      branches: new Set(),
    };
    entry.failures++;
    if (r.headBranch) entry.branches.add(r.headBranch);
    byWorkflow.set(name, entry);
  }
  return [...byWorkflow.values()]
    .map((e) => ({
      workflow: e.workflow,
      failures: e.failures,
      branches: e.branches.size,
    }))
    .sort((a, b) => b.failures - a.failures);
}

/**
 * Up to `n` runs to read the failed steps of, taken round-robin across the
 * workflows that failed, newest first within each. The newest `n` runs alone
 * would all come from whichever housekeeping workflow fails most (a cache
 * cleanup, a title check), and say nothing about the test workflow.
 */
export function spreadAcrossWorkflows(runs, n) {
  const byWorkflow = new Map();
  for (const r of runs) {
    const name = r.workflowName || r.name || '(unnamed)';
    if (!byWorkflow.has(name)) byWorkflow.set(name, []);
    byWorkflow.get(name).push(r);
  }
  const queues = [...byWorkflow.values()];
  const picked = [];
  for (let round = 0; picked.length < n; round++) {
    let any = false;
    for (const q of queues) {
      if (round < q.length && picked.length < n) {
        picked.push(q[round]);
        any = true;
      }
    }
    if (!any) break;
  }
  return picked;
}

/** The failed jobs and steps of one workflow run. */
async function failedJobs(ctx, runId) {
  const view = await runJson(
    'gh',
    ['run', 'view', String(runId), '--repo', repoArg(ctx), '--json', 'jobs'],
    { cwd: ctx.root },
  );
  if (!view.ok) return { error: view.error };
  return {
    jobs: (view.data.jobs ?? [])
      .filter((j) => j.conclusion === 'failure')
      .slice(0, 10)
      .map((j) => ({
        name: j.name,
        failedSteps: (j.steps ?? [])
          .filter((s) => s.conclusion === 'failure')
          .map((s) => s.name),
      })),
  };
}

/** Failed workflow runs triggered by pull requests in the window. */
export async function collectCiFailures(ctx) {
  const listed = await runJson(
    'gh',
    [
      'run',
      'list',
      '--repo',
      repoArg(ctx),
      '--event',
      'pull_request',
      '--status',
      'failure',
      '--created',
      `>=${ctx.since}`,
      '--limit',
      String(ctx.limits.maxRuns),
      '--json',
      'databaseId,workflowName,name,headBranch,displayTitle,createdAt,url',
    ],
    { cwd: ctx.root },
  );
  if (!listed.ok) return failed(`gh run list failed: ${listed.error}`);
  const runs = listed.data;
  const recent = spreadAcrossWorkflows(runs, FAILED_RUN_DETAILS);
  const details = await mapLimit(recent, FETCH_CONCURRENCY, (r) =>
    failedJobs(ctx, r.databaseId),
  );
  return {
    status: 'ok',
    runsRead: runs.length,
    truncated: runs.length >= ctx.limits.maxRuns,
    byWorkflow: summarizeFailures(runs),
    recent: recent.map((r, i) => ({
      workflow: r.workflowName || r.name,
      branch: r.headBranch,
      title: r.displayTitle,
      createdAt: r.createdAt,
      url: r.url,
      ...details[i],
    })),
  };
}

/** All GitHub sources, each skipped with the same reason when `gh` is unusable. */
export async function collectGithub(ctx) {
  const preflight = await githubPreflight(ctx.root, ctx.repoFlag);
  if (!preflight.ok) {
    const s = skipped(preflight.reason);
    return { repo: null, reviewFeedback: s, bugFixPrs: s, ciFailures: s };
  }
  const gh = { ...ctx, repo: preflight.repo, host: preflight.host };
  const [prs, ciFailures] = await Promise.all([
    collectPullRequests(gh),
    collectCiFailures(gh),
  ]);
  return { repo: preflight.repo, ...prs, ciFailures };
}

README.md

tile.json