Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions packages/core/src/fakes/github-fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ export type GithubFakeState = {
state: "open" | "closed" | "all";
labels?: readonly string[];
updatedWithinDays?: number;
maxPages?: number;
/**
* Recorded, never simulated — the fake holds one page, so it can never
* truncate. A dedup read's correctness depends on ASKING for `strict`, and
* that is the half a test can pin here; the truncation itself is pinned
* against the wire in `issues.test.ts`.
*/
strict?: boolean;
}>;
/** Every `actionRuns` call, in order. */
readonly actionRunsCalls: Array<{
Expand All @@ -72,6 +80,20 @@ export type GithubFakeState = {
readonly pullReviewCalls: PullReviewRequest[];
/** Every `openDraftPullRequest` call, in order. */
readonly openDraftPullRequestCalls: OpenDraftPullRequest[];
/**
* Every `openIssue` call, in order — and each one also lands in `issues`.
*
* That second half is the point. A run whose whole job is "do not file what I
* already filed" can only be tested if a filed issue is visible to the next
* read, so the fake appends it rather than merely recording the call. Without
* that, every test would pass against a run that dedups against nothing.
*/
readonly openIssueCalls: Array<{
repo: string;
title: string;
body: string;
labels?: readonly string[];
}>;
/** Every `createRelease` call, in order — lets a test assert a release published. */
readonly createReleaseCalls: CreateRelease[];
/** Every label ADD, in order. */
Expand Down Expand Up @@ -133,6 +155,7 @@ export const makeGithubFake = (
readTextFileCalls: [],
pullReviewCalls: [],
openDraftPullRequestCalls: [],
openIssueCalls: [],
createReleaseCalls: [],
addIssueLabelsCalls: [],
removeIssueLabelCalls: [],
Expand All @@ -146,9 +169,9 @@ export const makeGithubFake = (
const openedBranches = new Set<string>();

const service: GithubService = {
issues: ({ repo, state: want = "open", labels, updatedWithinDays }) =>
issues: ({ repo, state: want = "open", labels, updatedWithinDays, maxPages, strict }) =>
Effect.sync(() => {
state.issuesCalls.push({ repo, state: want, labels, updatedWithinDays });
state.issuesCalls.push({ repo, state: want, labels, updatedWithinDays, maxPages, strict });
const need = labels === undefined ? undefined : new Set(labels);
return state.issues.filter((i) => {
if (i.repo !== repo) return false;
Expand All @@ -164,6 +187,34 @@ export const makeGithubFake = (
});
}),

openIssue: ({ repo, title, body, labels }) =>
Effect.sync(() => {
state.openIssueCalls.push({ repo, title, body, labels });
// Numbered above every issue the fake knows about, in this repo or any
// other, because GitHub's numbering is per repo but a test asserting on
// `#3` should not have it mean two different issues.
const number = state.issues.reduce((max, i) => Math.max(max, i.number), 0) + 1;
const url = `https://github.com/${repo}/issues/${number}`;
state.issues = [
...state.issues,
{
repo,
number,
title,
body,
state: "open" as const,
labels: [...(labels ?? [])],
author: "flare-dispatch[bot]",
authorAssociation: "OWNER",
url,
commentCount: 0,
createdAt: now,
updatedAt: now,
},
];
return { number, url };
}),

// The writes record and mutate the seeded issue, so a test can assert both
// "the call was made" and "the state machine advanced".
addIssueLabels: ({ repo, issue, labels }) =>
Expand Down Expand Up @@ -195,7 +246,9 @@ export const makeGithubFake = (
Effect.sync(() => {
state.closeIssueAsDuplicateCalls.push({ repo, issue, duplicateOf });
state.issues = state.issues.map((i) =>
i.repo === repo && i.number === issue ? { ...i, state: "closed" as const } : i,
i.repo === repo && i.number === issue
? { ...i, state: "closed" as const, closedAt: now }
: i,
);
}),

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export {
github,
Github,
type GithubService,
type IssueCreated,
type IssueRef,
type PullRequestHistoryRef,
type ReadTextFileRequest,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/primitives/automerge-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ describe("evaluateAutomerge — a self-declared run marker is not authorship", (

const githubService = (over: Partial<GithubService>): GithubService => ({
issues: () => Effect.succeed([]),
openIssue: () => Effect.succeed({ number: 1, url: "" }),
addIssueLabels: () => Effect.void,
removeIssueLabel: () => Effect.void,
commentOnIssue: () => Effect.void,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/primitives/suppression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ describe("decideSuppression", () => {
/** A `GithubService` with every method stubbed, overridable per test. */
const githubService = (over: Partial<GithubService>): GithubService => ({
issues: () => Effect.succeed([]),
openIssue: () => Effect.succeed({ number: 1, url: "" }),
addIssueLabels: () => Effect.void,
removeIssueLabel: () => Effect.void,
commentOnIssue: () => Effect.void,
Expand Down
59 changes: 38 additions & 21 deletions packages/core/src/primitives/suppression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,18 @@ export type CheckSuppressionArgs = {
readonly ledgerRef?: string;
/** The repo prior proposals were opened against — defaults to `ledgerRepo`. */
readonly proposalRepo?: string;
/** The head-branch prefix every proposal of this kind shares. */
readonly headBranchPrefix: string;
/**
* The head-branch prefix every proposal of this kind shares.
*
* **Omit it for a consumer that opens no PRs**, and the cooldown half is
* skipped entirely — no PR-history read, no spend, no cooldown. That is not a
* degraded mode: a cooldown dated from a closed PR is meaningless where no PR
* exists, and passing a prefix that matches nothing would answer "no prior
* proposals" every tick for a reason no reader could tell apart from "the
* feature is off". The spec-audit sweep is the first such consumer — its
* memory is the issue it filed, and only the ledger half applies here.
*/
readonly headBranchPrefix?: string;
/** Now, in epoch ms — passed in so a run's clock is the one that decides. */
readonly nowMs: number;
/** Cooldown length — defaults to {@link COOLDOWN_DAYS_DEFAULT}. */
Expand Down Expand Up @@ -464,25 +474,32 @@ export const checkSuppression = (args: CheckSuppressionArgs) =>
);
}

// 2. The cooldowns. Paginate no further back than the cooldown window —
// `updatedAt >= closedAt`, so nothing closed inside it can be missed.
const priorProposals = yield* github
.pullRequestHistory({
repo: proposalRepo,
headBranchPrefix: args.headBranchPrefix,
state: "all",
updatedWithinDays: cooldownDays,
})
.pipe(
Effect.catchTag("GitHubApiError", (err) =>
Effect.gen(function* () {
const why = `PR history for ${proposalRepo} (${args.headBranchPrefix}*) unreadable (GitHub ${err.status} ${err.reason}) — cooldowns NOT applied this tick`;
degraded.push(why);
yield* io.log("warn", `suppression: ${why}`);
return [] as readonly PullRequestHistoryRef[];
}),
),
);
// 2. The cooldowns — only for a consumer that opens PRs. With no prefix
// there is no PR to have been closed, so the read is skipped rather than
// made and ignored: an empty history would otherwise be recorded as "no
// prior proposals", which reads the same as a working cooldown finding
// nothing.
const headBranchPrefix = args.headBranchPrefix;
const priorProposals =
headBranchPrefix === undefined
? ([] as readonly PullRequestHistoryRef[])
: yield* github
.pullRequestHistory({
repo: proposalRepo,
headBranchPrefix,
state: "all",
updatedWithinDays: cooldownDays,
})
.pipe(
Effect.catchTag("GitHubApiError", (err) =>
Effect.gen(function* () {
const why = `PR history for ${proposalRepo} (${headBranchPrefix}*) unreadable (GitHub ${err.status} ${err.reason}) — cooldowns NOT applied this tick`;
degraded.push(why);
yield* io.log("warn", `suppression: ${why}`);
return [] as readonly PullRequestHistoryRef[];
}),
),
);

const verdicts = decideSuppression({
candidates: args.keys,
Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/services/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ export type IssueRef = {
readonly createdAt: number;
/** epoch ms. */
readonly updatedAt: number;
/**
* epoch ms when the issue was closed, or `undefined` while it is open.
*
* `updatedAt` cannot stand in: any touch resets it, so a window dated from it
* never expires. This is the column the org store's `pulls` table lacks and
* `issues` has — the whole reason an issue-shaped ledger needs no workaround
* where a PR-shaped one needed a file in git.
*/
readonly closedAt?: number;
};

/** The outcome of {@link GithubService.openIssue}. */
export type IssueCreated = {
readonly number: number;
/**
* The issue's web URL. Never empty: a caller announces the issue by linking
* it, so a create that came back without one fails rather than publishing a
* link to nowhere.
*/
readonly url: string;
};

/**
Expand Down Expand Up @@ -394,9 +414,43 @@ export interface GithubService {
labels?: readonly string[];
updatedWithinDays?: number;
maxPages?: number;
/**
* Fail rather than return a list the page ceiling cut short.
*
* A triage pass wants the default: the 500 most recently updated issues are
* the tick's work and a longer backlog waits. A **deduplication** read
* cannot — it asks "have I filed this already?", and a truncated list says
* "no" for every issue it did not reach, so the caller duplicates whatever
* fell off the end. Set it wherever an absent row is read as a fact.
*/
strict?: boolean;
installationId?: number;
}) => Effect.Effect<readonly IssueRef[], GitHubApiError>;

/**
* Open one issue — the write the spec-audit sweep files an open question with.
*
* **Fails rather than degrading to a logged no-op**, which is the opposite of
* `openDraftPullRequest` below, and the difference is what the artifact is. A
* PR write that no-ops loses nothing: the branch is idempotent and the content
* is a file that still exists in the commit the next tick will re-derive. Here
* the issue *is* the question — there is no file, no branch, and no second
* copy — so a silent no-op drops it, and the loop then has no record that it
* ever had something to ask.
*
* Narrow on purpose: a title, a body, and labels. No assignee, no milestone,
* no template. What bounds it is the caller — the sweep files into one control
* repo resolved from config, and never files a question it did not first fail
* to find among that repo's existing issues.
*/
readonly openIssue: (req: {
repo: string;
title: string;
body: string;
labels?: readonly string[];
installationId?: number;
}) => Effect.Effect<IssueCreated, GitHubApiError>;

/** Add labels to an issue — the state machine's write (§5). */
readonly addIssueLabels: (req: {
repo: string;
Expand Down Expand Up @@ -497,8 +551,16 @@ export const github = {
labels?: readonly string[];
updatedWithinDays?: number;
maxPages?: number;
strict?: boolean;
installationId?: number;
}) => Effect.flatMap(Github, (g) => g.issues(opts)),
openIssue: (req: {
repo: string;
title: string;
body: string;
labels?: readonly string[];
installationId?: number;
}) => Effect.flatMap(Github, (g) => g.openIssue(req)),
addIssueLabels: (req: {
repo: string;
issue: number;
Expand Down
3 changes: 3 additions & 0 deletions packages/github-app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,16 @@ export {
export {
addIssueLabels,
closeIssueAsDuplicate,
createIssue,
createIssueComment,
listIssues,
removeIssueLabel,
type AddIssueLabelsOptions,
type AuthorAssociation,
type CloseIssueAsDuplicateOptions,
type CreateIssueCommentOptions,
type CreateIssueOptions,
type CreateIssueResult,
type IssueListItem,
type ListIssuesOptions,
type RemoveIssueLabelOptions,
Expand Down
Loading
Loading