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.
88%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
#!/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);
},
);
}