import type { GhIssue } from "./gh.js"; export declare const NEEDS_HUMAN_LABEL: "needs-human"; export declare const IN_PROGRESS_LABEL: "๐Ÿค– in-progress"; export declare const WAITING_ON_LABEL: "โณ waiting-on"; export declare const NEEDS_REPORTER_APPROVAL_LABEL: "needs-reporter-approval"; /** GitHub's own author-association values that count as "inside the code org". * Mirrors pr-state.ts and the server's trustedAuthorAssociations โ€” the values * are GitHub's enum, not a ShipFlow invention, so the three cannot drift. * * KNOWN, DELIBERATE ASYMMETRY (PR #450 review round 5): GitHub returns * `COLLABORATOR` for ANY invited collaborator, including an **outside * collaborator with read-only permission**, whereas REMOVING the gate label * needs triage or above. So the set that skips the gate is strictly wider than * the set that can release it: a read-only outside collaborator's own issues * are admitted ungated, while that same account cannot approve anyone else's. * Narrowing to OWNER/MEMBER was rejected โ€” it would gate every write-access * contractor (the common case) to close a rare one, and it would fork this * list away from pr-state.ts and the server, which is the drift this constant * exists to prevent. The caveat is documented alongside the permission claim in * `loop-mode.md` ยง Intake gate so code and doc agree: the operator-side control * is not to invite read-only outside collaborators on a repo the loop builds. */ export declare const TRUSTED_ISSUE_AUTHOR_ASSOCIATIONS: string[]; /** True when an issue was opened from OUTSIDE the code org, so the loop must not * build it until a maintainer approves. Unknown/absent association fails CLOSED * (treated as outside): a gate whose premise is "someone in the org asked for * this" must not be released by a field we could not read. */ export declare function isOutsideCodeOrg(authorAssociation: string | undefined | null): boolean; /** Filters that narrow which open issues the work loop will claim. */ export interface PickupFilter { /** Whether the issue is already exclusively claimed by an agent. */ claimed: boolean; /** When set, only issues carrying this label are actionable. Compared * CASE-INSENSITIVELY (trim + fold, both sides) because GitHub treats label * names case-insensitively โ€” `gh issue list --label Bug` returns the `bug` * set, and reports it under the canonical spelling โ€” so an exact `===` here * discarded every issue a capitalised filter matched upstream and made the * queue read as empty (#660, sibling of #646). */ label?: string; /** When set, only issues assigned to this login are actionable. Compared * CASE-INSENSITIVELY (trim + fold, both sides) because the server-side * `gh issue list --assignee` fetch this filter re-checks already matches * logins case-insensitively โ€” an exact `===` here made a mixed-case * spelling `gh` accepted discard every issue it returned, so the queue * looked empty and `issue next` exited 4 (#646). */ assignee?: string; /** Effective intake-approval mode (#448). `off` disables the intake gate * ENTIRELY โ€” including for issues already carrying * `needs-reporter-approval` from an earlier pass. Threading it here is what * makes `off` a real escape hatch: no code path removes that label * automatically โ€” reporter/association-side clearing is tracked in #473 and * unshipped โ€” so without this a * mass-arming event stayed unrecoverable even after the operator turned the * gate off (PR #450 review). Omitted => gate enforced (fail closed). * * Note this is the REPO-GLOBAL release. The PER-ISSUE release is a * maintainer removing the label by hand, which sticks because arming is a * one-time event โ€” see `decideIntakeGate`. */ intakeMode?: IntakeApprovalMode; } /** Effective intake-approval modes (#448). Mirrors `resolveIntakeApproval`'s * return type so an `=== "off"` comparison cannot drift on a typo. */ export type IntakeApprovalMode = "code-org" | "reporter" | "off"; /** * True when an open issue is eligible for the work loop to claim. Excludes * issues that are already claimed, escalated to a human (`needs-human`), * marked in-progress, parked on a dependency (`โณ waiting-on`), or awaiting a * maintainer's intake approval (`needs-reporter-approval`, #448), plus any that * fail an explicit --label / --assignee filter. Pure (depends only on its arguments) so it's unit-testable. */ export declare function isActionableForPickup(issue: GhIssue, filter: PickupFilter): boolean; /** A resolved dependency reference: the repo it lives in + its number. */ export interface DependencyRef { repo: string; number: number; } /** * Parse a user-supplied dependency reference into {repo, number}. Accepts * `#123`, `123`, `owner/repo#123`, and full GitHub issue/PR URLs; bare numbers * resolve against defaultRepo. Cross-repo refs are first-class โ€” the motivating * case (ApeironDuels#20) waited on PRs in the same repo, but a dependency may * live anywhere the loop can read. Returns null when nothing parses. */ export declare function parseDependencyRef(ref: string, defaultRepo: string): DependencyRef | null; /** Hidden, machine-readable marker embedded in the โณ wait comment. */ export declare function formatWaitingOnMarker(dep: DependencyRef): string; /** * Extract the dependency from an issue's comments โ€” the NEWEST * `shipflow:waiting-on` marker wins, so a re-wait on a different dependency * supersedes older ones. Returns null when no marker exists (a hand-applied * label with no marker can only be cleared by a human). */ export declare function extractWaitingOnDep(comments: { body: string; }[]): DependencyRef | null; /** Hidden, machine-readable marker embedded in the intake-gate comment. */ export declare const INTAKE_GATE_MARKER = ""; /** * True when an issue's comments already carry a LOOP-AUTHORED intake-gate * marker, i.e. the gate was ARMED on some earlier pass. Its presence is what * makes a maintainer's label removal STICK: without it, `isOutsideCodeOrg` * alone is the arming condition, and that never stops being true for an outside * author โ€” so every `issue next` pass re-applied `needs-reporter-approval` * before the picker ran and the documented per-issue release did nothing * (PR #450 review round 3). * * The AUTHOR CHECK is a security boundary, not a nicety (PR #450 review round * 4). Anyone who can see an issue can comment on it โ€” no write access needed โ€” * so a body-only match makes the marker a FORGEABLE TRUST TOKEN: an outside * filer posts the invisible `` on their own issue * before the loop's first pass, `decideIntakeGate` reads `armedBefore = true`, * returns `none`, the label is never applied, and the loop claims and builds an * unapproved issue โ€” a complete bypass of the gate this code exists to install. * Only the CLI identity's own comments count, exactly as * `findLatestEscalationComment` (escalation-format.ts) filters its own banner. * * `viewerDidAuthor` is REQUIRED, not optional (PR #450 review round 5). It was * optional, defaulting to "ours", which is safe for today's single caller * (`ghIssueComments` always supplies a real boolean) but meant a future caller * mapping a different comment source would silently fail OPEN with no compiler * help. Requiring it removes the fail-open branch entirely at zero cost. */ export declare function hasIntakeGateMarker(comments: { body: string; viewerDidAuthor: boolean; }[]): boolean; /** * What the LIVE approval evidence says about an armed issue. * - `approved` โ€” the gate label was genuinely removed by a known actor, and * the issue has not been edited since. * - `unapproved` โ€” no positive removal evidence. The label is presumed STILL * LIVE on GitHub whatever our snapshot said. * - `stale` โ€” it was removed, but the body or the TITLE changed AFTER * that approval, so what a maintainer approved is not what * the worker would be handed. */ export type IntakeApprovalState = "approved" | "unapproved" | "stale"; /** * Classify an armed issue's approval from LIVE GitHub evidence: the `unlabeled` * timeline events for `needs-reporter-approval`, the issue's `lastEditedAt`, * and its title-rename events. * * Why positive removal evidence is required (PR #450 review round 5 โ€” HIGH). * `issue next` snapshots every open issue's labels ONCE, then reads the marker * LIVE per issue. Under the supported two-loops-on-one-repo deployment * (`all_candidates_raced`) sharing one gh identity those two sources disagree: * loop A arms #N (label + marker); loop B's snapshot predates the label, its * live read sees A's marker as its own, and the old `armedBefore โ‡’ none` branch * inferred "a maintainer removed the label" from the label merely being ABSENT * from a stale read โ€” admitting an unapproved outside issue. A removal is a * TIMELINE EVENT; requiring it turns an inference into a fact, and a snapshot * that is merely stale now produces `unapproved` (withhold) rather than admit. * * Why `actorKnown` and not `!actorIsBot`: removing a label needs triage or * above, so ANY named actor held the permission the approval rests on. A NULL * actor is the one fail-open shape (PR #441's lesson) and is rejected. Unlike * the #441 intent gate, nothing in ShipFlow removes this label itself (#473 is * unshipped), so a bot removal is operator-installed automation acting with * triage+, not the system clearing its own gate โ€” refusing it would strand the * issue with no automated way back. * * Why content binding at all (round 5 โ€” MEDIUM, approve-then-swap). Approval * bound to the LABEL, not to the CONTENT: arming is one-time, so a filer could * wait for the removal and then rewrite the issue into build instructions the * maintainer never saw, and the loop feeds `ghIssueView`'s current text to the * worker. No race required. Comparing against the removal's own timestamp binds * approval to the approved content while leaving edits made BEFORE the approval * alone โ€” the maintainer saw those. A change after it yields `stale`, which * re-arms so the maintainer re-approves what is actually there. * * Why BOTH `lastEditedAt` and `renamedAt` (round 6 โ€” MEDIUM). GitHub's * `Issue.lastEditedAt` tracks **body edits only**; a title change is a * `RenamedTitleEvent` and does not touch it (verified live โ€” `golang/go` * #80581, `cli/cli` #13924, and this repo's #460/#447/#440/#435/#402 are all * renamed issues whose `lastEditedAt` is null or older than the rename). So * `lastEditedAt` alone still let an approved issue be RETITLED afterwards, and * on a thin- or empty-bodied issue the title IS the spec โ€” a full post-approval * swap. A rename after the approval is treated exactly like a body edit after * it. The rename timestamps ride along on the timeline read `ghLabelRemovals` * already makes, so this costs no extra API call. * * Unparseable/absent timestamps fail CLOSED (a removal we cannot place in time * is no evidence; a change we cannot place in time is not "unchanged"). */ export declare function classifyIntakeApproval(removals: { actorKnown: boolean; createdAt: string; }[], lastEditedAt: string | null | undefined, renamedAt: string[]): IntakeApprovalState; /** What `issue next` should do about the intake gate for one open issue. * - `none` โ€” gate does not apply, or has already had its say. * - `check-marker` โ€” need the issue's comments to tell armed-before from not; * re-ask with `armedBefore` resolved. * - `check-approval` โ€” armed before: need LIVE approval evidence (label-removal * timeline + `lastEditedAt`); re-ask with `approval` set. * - `gate-unapproved`โ€” armed, but no positive removal evidence: the label is * presumed still live. Withhold IN MEMORY, write nothing. * - `gate-this-pass` โ€” association unreadable. Withhold IN MEMORY, write nothing. * - `arm` โ€” apply the label and write the one-time marker. */ export type IntakeGateAction = "none" | "check-marker" | "check-approval" | "gate-unapproved" | "gate-this-pass" | "arm"; /** * Decide the intake-gate action for one open issue. Pure โ€” the caller supplies * `armedBefore` (`null` when it has not looked the marker up yet, which is what * `check-marker` asks it to do, and also what an unreadable comment fetch must * report so the decision fails closed without persisting anything). * * The one-time property is the whole point: `arm` is reachable only while NO * marker exists, or when a live approval has gone `stale`. Once armed, the label * is the maintainer's to remove and the loop never re-applies it โ€” that is the * per-issue approval path `loop-mode.md` documents. (Automatic clearing on a * trusted approval is a separate, unshipped follow-up: #473.) * * A MARKER IS NOT AN APPROVAL. `armedBefore` says only "this loop armed this * issue at some point"; the approval itself is a `needs-reporter-approval` * REMOVAL EVENT, which `classifyIntakeApproval` reads live. Inferring approval * from the label being absent in the caller's (once-taken, possibly stale) * snapshot is exactly the concurrent-loop bypass round 5 reproduced โ€” see that * function's contract. So `armedBefore` routes to `check-approval`, never * straight to `none`. * * ORDER MATTERS: the marker/approval pair is consulted BEFORE * `associationLookupFailed`, so a maintainer's approval keeps being honoured * through an association outage (PR #450 review round 4, re-verified in round 5 * by a 576-state sweep). Reading first cannot open the gate โ€” a marker only * exists because THIS loop armed the issue on an earlier pass when the * association WAS readable (`hasIntakeGateMarker` rejects forged ones), the * removal event is permission-enforced, and the no-marker outage path still ends * at `gate-this-pass`, so the fail-closed guarantee is unchanged: nothing is ever * persisted from a lookup we could not read. The cost is that an outage widens * the comment fetch from outside-org issues to every unlabelled open issue for * that pass โ€” bounded, per-pass, and cheaper than silently un-approving issues a * human already released. */ export declare function decideIntakeGate(issue: Pick, ctx: { intakeMode?: IntakeApprovalMode; armedBefore: boolean | null; /** Live approval evidence; `null`/absent = not looked up yet. */ approval?: IntakeApprovalState | null; }): IntakeGateAction; /** * True when an issue's `๐Ÿค– in-progress` label is STALE: nothing is actually * working it โ€” no live claim and no open PR that closes it or names it with * `Part of #N` (issue #634). Claims TTL-expire silently (2h) while the label * only clears on an explicit release the default `manual` merge policy never * sends, so crashed sessions and parked PRs orphan their issues; without * healing, `isActionableForPickup` then skips them forever (issue #216). * Pure โ€” the caller supplies the live-claim and open-PR sets. */ export declare function isStaleInProgress(issue: GhIssue, claimed: Set, openPRIssues: Set): boolean; /** * Orders issues for the work loop: highest `priority:*` first, then highest * `severity:*`, then most recently created (latest wins on an otherwise-equal * tie). Returns a new array; the input is not mutated. */ export declare function sortIssuesForPickup(issues: T[]): T[]; //# sourceMappingURL=issue-order.d.ts.map