Skip to content
Draft
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
7 changes: 6 additions & 1 deletion packages/tbd/src/cli/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
formatNoSpecGroupHeader,
type IssueForDisplay,
} from '../lib/issue-format.js';
import { parseDateOption } from '../lib/issue-input-validation.js';
import { parsePriority } from '../../lib/priority.js';
import { selectIssues } from '../../lib/issue-query.js';
import type { IssueQuery, IssueSort } from '../../lib/issue-query.js';
Expand Down Expand Up @@ -215,6 +216,10 @@ class ListHandler extends BaseCommand {
// An empty --spec means "no filter", as before.
spec: options.spec === '' ? null : (options.spec ?? null),
deferred: options.deferred ?? false,
deferBefore:
options.deferBefore === undefined
? null
: parseDateOption(options.deferBefore, '--defer-before'),
ready: false,
sort: (options.sort ?? 'priority') as IssueSort,
limit: null,
Expand All @@ -240,7 +245,7 @@ export const listCommand = new Command('list')
'Filter by spec path (matches full path, partial path suffix, or filename)',
)
.option('--deferred', 'Show only deferred issues')
.option('--defer-before <date>', 'Deferred before date')
.option('--defer-before <date>', 'Only issues whose deferred_until falls before this date')
.option('--sort <field>', 'Sort by: priority, created, updated', 'priority')
.option('--limit <n>', 'Limit results')
.option('--count', 'Output only the count of matching issues')
Expand Down
10 changes: 1 addition & 9 deletions packages/tbd/src/cli/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type {
import { now } from '../../utils/time-utils.js';
import { resolveToInternalId, type IdMapping } from '../../file/id-mapping.js';
import { resolveSpecArg, getPathErrorMessage } from '../../lib/project-paths.js';
import { validateIssueTitle } from '../lib/issue-input-validation.js';
import { parseDateOption, validateIssueTitle } from '../lib/issue-input-validation.js';
import { checkParentAssignment, describeHierarchyProblem } from '../../lib/issue-hierarchy.js';
import { withDataSyncContext } from '../lib/data-context.js';
import {
Expand Down Expand Up @@ -68,14 +68,6 @@ interface UpdateOptions {
* anything it cannot is refused by name, rather than reaching the schema and coming back
* as "Invalid datetime".
*/
function parseDateOption(value: string, flag: string): string {
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) {
throw new ValidationError(`Invalid ${flag} value: ${value}. Expected a date or timestamp.`);
}
return new Date(parsed).toISOString();
}

class UpdateHandler extends BaseCommand {
async run(ids: string[], options: UpdateOptions): Promise<void> {
if (ids.length === 1) {
Expand Down
1 change: 1 addition & 0 deletions packages/tbd/src/cli/lib/integration-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ export async function runEnabledIntegrationPushes(
const plan = planMirror({
provider: entry.provider,
allIssues,
readyAt: Date.now(),
selected,
displayId,
mirrorLabels: resolveProviderSettings(config.integrations?.linear ?? {}).mirrorLabels,
Expand Down
15 changes: 15 additions & 0 deletions packages/tbd/src/cli/lib/issue-input-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,18 @@ export function validateIssueTitle(title: string, options: IssueTitleValidationO

return title;
}

/**
* Parse a user-supplied date flag into a normalized ISO timestamp.
*
* Shared by every date-valued flag so an unusable value fails loudly at the CLI
* boundary. A filter that silently accepts garbage and then matches everything is
* indistinguishable to the caller from a filter that did not run.
*/
export function parseDateOption(value: string, flag: string): string {
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) {
throw new ValidationError(`Invalid ${flag} value: ${value}. Expected a date or timestamp.`);
}
return new Date(parsed).toISOString();
}
25 changes: 19 additions & 6 deletions packages/tbd/src/file/doc-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,12 @@ const SHORTCUT_DIRECTORY_END = '<!-- END SHORTCUT DIRECTORY -->';
interface GuidelineGroup {
heading: string;
note?: string;
match: (name: string) => boolean;
/**
* `category` is the doc's declared frontmatter category. Prefer it over the name:
* doc-categories.ts calls name inference retired, and a prefix test silently
* misfiles a sibling whose name does not share the prefix.
*/
match: (name: string, category?: string) => boolean;
}

/**
Expand Down Expand Up @@ -452,6 +457,7 @@ const CROSS_CUTTING_NAMES = new Set([
'general-testing-rules',
'golden-testing-guidelines',
'release-engineering-rules',
'release-notes-guidelines',
'supply-chain-hardening',
]);

Expand Down Expand Up @@ -486,8 +492,7 @@ const GUIDELINE_GROUPS: GuidelineGroup[] = [
{
heading: 'TypeScript & JS ecosystem',
note: 'Select the documents that match the TypeScript or JavaScript surface; do not load this whole group by default.',
match: (n) =>
n.startsWith('typescript-') || n.endsWith('monorepo-patterns') || n.startsWith('electron-'),
match: (n) => n.startsWith('typescript-') || n.endsWith('monorepo-patterns'),
},
{
heading: 'Python',
Expand All @@ -504,6 +509,14 @@ const GUIDELINE_GROUPS: GuidelineGroup[] = [
note: 'Select the documents that match the Convex surface; do not load this whole group by default.',
match: (n) => n.startsWith('convex-'),
},
{
heading: 'Desktop app frameworks',
note: 'Select the document for the framework in use; do not load this whole group by default.',
// Matched on the declared category, not the name. `electron-`, `electrobun-`, and
// `tauri-` share no prefix, which is exactly how two of the three used to land in
// the catch-all while the third was filed under TypeScript.
match: (_n, category) => category === 'desktop',
},
{
// Catch-all, must stay last.
heading: 'Docs, process & tooling',
Expand All @@ -518,8 +531,8 @@ const GUIDELINE_GROUPS: GuidelineGroup[] = [
* the only other place this logic surfaces, and a misfiled guideline there is
* easy to miss.
*/
export function guidelineGroupFor(name: string): string {
const group = GUIDELINE_GROUPS.find((g) => g.match(name));
export function guidelineGroupFor(name: string, category?: string): string {
const group = GUIDELINE_GROUPS.find((g) => g.match(name, category));
// The catch-all matches everything, so this fallback is unreachable in practice.
return group?.heading ?? GUIDELINE_GROUPS[GUIDELINE_GROUPS.length - 1]!.heading;
}
Expand Down Expand Up @@ -612,7 +625,7 @@ export function generateShortcutDirectory(
const grouped = GUIDELINE_GROUPS.map((group) => ({ group, docs: [] as CachedDoc[] }));
const catchAll = grouped[grouped.length - 1];
for (const doc of guidelines) {
const heading = guidelineGroupFor(doc.name);
const heading = guidelineGroupFor(doc.name, doc.frontmatter?.category);
const entry = grouped.find((g) => g.group.heading === heading) ?? catchAll;
entry?.docs.push(doc);
}
Expand Down
13 changes: 11 additions & 2 deletions packages/tbd/src/integrations/core/mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export interface MirrorContext {
provider: ProviderNameType;
/** Every bead, needed for child counts and readiness. */
allIssues: readonly Issue[];
/**
* The instant readiness is evaluated at, since a `deferred_until` in the future
* makes a bead not-ready. Supplied by callers so one mirror run cannot straddle
* two clock reads, and so tests can pin it; defaults to now.
*/
readyAt?: number;
/** The beads to mirror. */
selected: readonly Issue[];
/** Renders an internal id the way a user sees it. */
Expand Down Expand Up @@ -195,12 +201,15 @@ export function attachmentsFor(
}

/**
* Compute what a mirror run would do. Pure.
* Compute what a mirror run would do.
*
* Pure given `context.readyAt`; without it the readiness cutoff is read from the
* clock once, here, rather than per bead.
*/
export function planMirror(context: MirrorContext): MirrorPlan {
const byId = new Map(context.allIssues.map((issue) => [issue.id, issue]));
const selectedIds = new Set(context.selected.map((issue) => issue.id));
const readyIds = readyIssueIds(context.allIssues);
const readyIds = readyIssueIds(context.allIssues, context.readyAt ?? Date.now());

const childrenOf = new Map<string, Issue[]>();
for (const issue of context.allIssues) {
Expand Down
12 changes: 10 additions & 2 deletions packages/tbd/src/lib/issue-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,16 @@ export function createIssueChanges(options: CreateIssueChangesOptions): IssueCha
const idsToCompare = explicitIds ?? candidateIds;
const needsReadySets = options.selection.kind === 'filter' && options.selection.ready;
const emptySet: ReadonlySet<string> = new Set();
const readyBefore = needsReadySets ? readyIssueIds(options.before.issues.values()) : emptySet;
const readyAfter = needsReadySets ? readyIssueIds(options.after.issues.values()) : emptySet;
// One instant for both snapshots: readiness depends on `deferred_until`, so two
// clock reads could report a deferral that merely elapsed between them as a ready
// transition that no edit caused.
const readyAt = Date.now();
const readyBefore = needsReadySets
? readyIssueIds(options.before.issues.values(), readyAt)
: emptySet;
const readyAfter = needsReadySets
? readyIssueIds(options.after.issues.values(), readyAt)
: emptySet;
const changes: IssueChange[] = [];

for (const internalId of Array.from(idsToCompare).sort((left, right) =>
Expand Down
27 changes: 27 additions & 0 deletions packages/tbd/src/lib/issue-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export interface IssueQuery {
spec: string | null;
/** `--deferred` */
deferred: boolean;
/**
* `--defer-before <date>`, already parsed to an ISO timestamp by the caller (as
* `priority` is): keep only beads whose `deferred_until` falls before this instant.
* Null means no filter. A bead with no `deferred_until` is not deferred before
* anything and is excluded whenever this is set.
*/
deferBefore: string | null;
/** `tbd ready` semantics: open, unassigned, and unblocked per `readyIssueIds`. */
ready: boolean;
/** `--sort` */
Expand All @@ -61,6 +68,7 @@ export function defaultIssueQuery(): IssueQuery {
parentId: null,
spec: null,
deferred: false,
deferBefore: null,
ready: false,
sort: 'priority',
limit: null,
Expand All @@ -82,6 +90,22 @@ export function selectIssues(issues: readonly Issue[], query: IssueQuery): Issue
return query.limit === null ? sorted : sorted.slice(0, query.limit);
}

/**
* Whether a bead carries a `deferred_until` strictly before `cutoff`.
*
* A bead with no deferral is not "deferred before" any date, so it is excluded rather
* than treated as deferred since the beginning of time. `cutoff` is already-parsed
* per the field contract, so an unusable value is rejected at the CLI boundary rather
* than degrading into a filter that matches everything.
*/
function deferredBefore(issue: Issue, cutoff: string): boolean {
if (issue.deferred_until == null) {
return false;
}
const until = Date.parse(issue.deferred_until);
return Number.isFinite(until) && until < Date.parse(cutoff);
}

/** Apply shared CLI query predicates without paying for ordering when only facets need rows. */
export function filterIssues(issues: readonly Issue[], query: IssueQuery): Issue[] {
const readyIds = query.ready ? readyIssueIds(issues) : null;
Expand Down Expand Up @@ -115,6 +139,9 @@ export function filterIssues(issues: readonly Issue[], query: IssueQuery): Issue
if (query.deferred && issue.status !== 'deferred') {
return false;
}
if (query.deferBefore !== null && !deferredBefore(issue, query.deferBefore)) {
return false;
}
if (readyIds !== null && !readyIds.has(issue.id)) {
return false;
}
Expand Down
33 changes: 32 additions & 1 deletion packages/tbd/src/lib/issue-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ export function issueMatchesSharedFilters(issue: Issue, filters: SharedIssueFilt
return true;
}

/**
* Whether a bead is still waiting out a `deferred_until` at `now`.
*
* A deferral exactly at `now` has arrived, so the work is available. An unparseable
* timestamp fails open — the schema validates the field, and hiding work because a
* date could not be read is the worse failure of the two.
*/
function deferralPending(issue: Issue, now: number): boolean {
if (issue.deferred_until == null) {
return false;
}
const until = Date.parse(issue.deferred_until);
return Number.isFinite(until) && until > now;
}

/**
* Compute ready issue IDs from one complete issue snapshot.
*
Expand All @@ -50,8 +65,21 @@ export function issueMatchesSharedFilters(issue: Issue, filters: SharedIssueFilt
* A held bead is never ready regardless of its dependencies: `blocked` means it is
* waiting on something and `paused` means it was deliberately set down, and offering
* either to an agent looking for work is how a hold gets quietly ignored.
*
* A `deferred_until` still in the future is the same kind of hold, written as a date
* instead of a flag. It used to be recorded and then ignored here, so a bead deferred
* to next year was offered as available work today and the field read as scheduling
* while scheduling nothing.
*
* `now` is a parameter rather than a `Date.now()` read inside the filter because
* `issue-changes.ts` computes this set twice to diff two snapshots: if each call took
* its own clock, a bead whose deferral elapsed between them would surface as a ready
* transition that no edit caused.
*/
export function readyIssueIds(issues: Iterable<Issue>): ReadonlySet<string> {
export function readyIssueIds(
issues: Iterable<Issue>,
now: number = Date.now(),
): ReadonlySet<string> {
const allIssues = Array.from(issues);
const issueById = new Map(allIssues.map((issue) => [issue.id, issue]));
const blockerIdsByTarget = new Map<string, string[]>();
Expand All @@ -73,6 +101,9 @@ export function readyIssueIds(issues: Iterable<Issue>): ReadonlySet<string> {
if (issue.status !== 'open' || issue.delegate || issue.hold) {
return false;
}
if (deferralPending(issue, now)) {
return false;
}
const blockerIds = blockerIdsByTarget.get(issue.id) ?? [];
return !blockerIds.some((blockerId) => issueById.get(blockerId)?.status !== 'closed');
})
Expand Down
Loading
Loading