@osdlc/repo-cleaner 0.13.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @osdlc/repo-cleaner
2
2
 
3
+ ## 0.14.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 5cba918: `apply` says `skipped`, not `dropped`, for rows it leaves alone, matching the `skip` label on each row: `skipped 12, not deleted: 8 needs-review (name with --row), 4 not-authorized (--allow abandon-unmerged)`. The counts name how to include each kind. The closing prune step reads `prune worktree records`, with the repository on the progress board, instead of a bare `prune`. The JSON receipt and summary keep their `dropped` field.
8
+
9
+ ## 0.14.1
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [a3ef86d]
14
+ - @osdlc/cli-tool@0.15.0
15
+
16
+ ## 0.14.0
17
+
18
+ ### Minor Changes
19
+
20
+ - cde983c: Pull request evidence now comes from a provider, and GitLab is one (prototype). `--provider auto|github|gitlab|none` on every sweeping command picks it; `auto`, the default, reads the remote's host, so a `gitlab` host asks `glab` for merge requests and any other host asks `gh` as before. `--no-gh` is `--provider none`. A plan records the choice for `apply`'s re-check. **Breaking (library):** `RepoInfo.provider` says which provider was asked and what its probe found, and replaces `hasGh` and `ghAuthenticated`. The `noGh` sweep and plan option is now `noProvider`. `fetchPrs` takes a provider (`githubProvider(gh)` for the old `Gh` form), and its third argument is an exact branch, not a prefix. The plan file keeps its `no_gh` field. Not yet done: Gitea, Forgejo, Bitbucket, a host-to-provider config, GitLab squash metadata, and GitLab installed under a sub-path. Without `--remotes` no provider is asked.
21
+
3
22
  ## 0.13.0
4
23
 
5
24
  ### Minor Changes
package/README.md CHANGED
@@ -34,7 +34,7 @@ own bar, empty while it waits, half full while it runs and full with a `✓`
34
34
  ██████████ ✓ delete local_branch feat/a
35
35
  █████░░░░░ › delete local_branch feat/b
36
36
  ░░░░░░░░░░ delete remote_branch origin/feat/b
37
- ░░░░░░░░░░ prune
37
+ ░░░░░░░░░░ prune worktree records in /Users/me/work/api
38
38
  ████████░░░░░░░░░░░░░░░░░░░░░░ 1/4
39
39
  ```
40
40
 
@@ -74,7 +74,7 @@ Three signals settle most items. The first two are enough alone; the third needs
74
74
 
75
75
  | Signal | Source |
76
76
  | --- | --- |
77
- | A `MERGED` pull request from this repository, into the default branch, whose head commit is the branch tip | `gh pr list`, one listing per repository |
77
+ | A `MERGED` pull request from this repository, into the default branch, whose head commit is the branch tip | `gh pr list` (or `glab` for GitLab), one listing per repository |
78
78
  | The branch tip is reachable from the default branch | `git merge-base --is-ancestor` |
79
79
  | The branch's changes are on the default branch under other commit ids (rebase merge, cherry-pick, squash merge) | `git cherry` plus `git merge-tree`, see [Patch equivalence](#patch-equivalence) |
80
80
 
@@ -226,8 +226,9 @@ table cannot be read the worktree is held back as `process-probe-failed`. A
226
226
  host or test supplies its own probe.
227
227
  Nothing is ever killed.
228
228
 
229
- Without `gh`, or without authentication, classification proceeds on git
230
- evidence alone and the report says so.
229
+ Without the provider's CLI (`gh`, or `glab` for GitLab), or without
230
+ authentication, classification proceeds on git evidence alone and the report
231
+ says so. See [Providers (prototype)](#providers-prototype).
231
232
 
232
233
  Every branch delete names the full sha the sweep judged. `update-ref -d` and
233
234
  the pinned `--force-with-lease` push both refuse when the branch has moved
@@ -350,7 +351,8 @@ Every command that sweeps takes these:
350
351
  | `--branch <name\|HEAD>` | One branch of this repository: its worktree and local branch, and its remote branch with `--remotes`. Refuses the default branch |
351
352
  | `--remote <name>` | Remote to measure against (default `origin`) |
352
353
  | `--remote-url <url>` | Sweep this remote's branches with no checkout of your own (see [A remote with no checkout](#a-remote-with-no-checkout)); repeatable |
353
- | `--no-gh` | With `--remotes`: classify on git evidence alone. Without `--remotes`, `gh` is never called |
354
+ | `--provider <name>` | With `--remotes`: where pull requests come from: `auto` (default, by the remote's host), `github`, `gitlab` or `none` |
355
+ | `--no-gh` | With `--remotes`: classify on git evidence alone; the same as `--provider none`. Without `--remotes`, no provider is asked |
354
356
  | `--no-fetch` | With `--remotes`: skip the fetch; the header then says `not fetched`. Without `--remotes`, nothing is fetched |
355
357
 
356
358
  `status` also takes:
@@ -393,13 +395,13 @@ asks `Delete N items? [y/N]`:
393
395
  /Users/me/work/api
394
396
  delete local_branch fix/login-redirect
395
397
  git update-ref -d refs/heads/fix/login-redirect 3f9c2e1d…
396
- prune
398
+ prune worktree records
397
399
  git -C /Users/me/work/api worktree prune
398
400
  skip worktree /Users/me/work/api/wt/spike
399
401
  needs-review: clean but no clear disposability signal; name it with --row to apply it
400
402
 
401
403
  would delete: 1 local branch
402
- dropped 1: 1 needs-review
404
+ skipped 1, not deleted: 1 needs-review (name with --row)
403
405
  Delete 2 items? [y/N]
404
406
  ```
405
407
 
@@ -432,7 +434,7 @@ Every run ends with what it did:
432
434
 
433
435
  ```text
434
436
  deleted: 1 worktree, 3 local branches, 2 remote branches, 1 stale worktree pruned
435
- dropped 2: 1 needs-review, 1 duplicate
437
+ skipped 2, not deleted: 1 needs-review (name with --row), 1 duplicate
436
438
  status: complete
437
439
  ```
438
440
 
@@ -463,7 +465,7 @@ that swept, or read its plan from stdin, writes a receipt only when
463
465
  ```ts
464
466
  import { sweepRepo, sweepRepos } from '@osdlc/repo-cleaner'
465
467
 
466
- const sweep = sweepRepo('/Users/me/work/api', { noGh: true })
468
+ const sweep = sweepRepo('/Users/me/work/api', { noProvider: true })
467
469
  for (const item of sweep.items) {
468
470
  console.log(item.classification, item.id, item.reason)
469
471
  }
@@ -497,10 +499,49 @@ or `needs-investigation`.
497
499
  An issue tracker's linkage, applied this way, is a host's own layer over the
498
500
  sweep and `applyPlan`.
499
501
 
502
+ ## Providers (prototype)
503
+
504
+ Pull request evidence comes from a provider. Classification reads the same
505
+ fields whichever one answered: number, state, head branch, head commit, target
506
+ branch.
507
+
508
+ | Provider | CLI | Listing |
509
+ | --- | --- | --- |
510
+ | `github` | `gh` | `gh pr list --state all`, up to 300 |
511
+ | `gitlab` | `glab` | `glab api projects/<path>/merge_requests?state=all`, 100 a page, up to 300 |
512
+
513
+ `--provider auto` (the default) reads the remote's host: a host with `gitlab`
514
+ in its name is GitLab, any other is GitHub, which is what the tool did before
515
+ providers existed. Name one with `--provider github|gitlab` for a host the
516
+ name does not give away, or `--provider none` to skip pull requests. A plan
517
+ records the choice, so `apply` re-checks with the same provider.
518
+
519
+ GitLab maps onto the same evidence: `opened` and `locked` are `OPEN`, `merged`
520
+ is `MERGED`, `closed` is `CLOSED`, `sha` is the head commit and `target_branch`
521
+ the base. A merge request from a fork (source project is not the target
522
+ project) is dropped, as a cross-repository PR is on GitHub. The project path is
523
+ read off the remote URL with its groups (`group/sub/name`), and `glab` is asked
524
+ for that host with `--hostname`.
525
+
526
+ Not done yet:
527
+
528
+ - Gitea, Forgejo and Bitbucket.
529
+ - Mapping a self-hosted host to a provider in config; today it is the host
530
+ name or `--provider`.
531
+ - Auth edge cases: tokens without API scope, `glab` signed in to a different
532
+ host than the remote, rate limits. Each reads as "no PR found".
533
+ - Squash metadata. GitLab reports `squash_commit_sha`; it is not used, so a
534
+ squashed MR proves landing only by its head commit, as on GitHub.
535
+ - GitLab under a sub-path (`https://host/gitlab/group/proj`). The URL does not
536
+ say where the install ends and the project begins, so the whole path is read
537
+ as the project, `glab` is asked for a project that does not exist, and the
538
+ listing fails. That reads as "no PR found": rows stay at `review`, nothing is
539
+ deleted on its account.
540
+
500
541
  ## Runtime dependencies
501
542
 
502
- `git` on `PATH`, and `gh` when PR state should count. Authentication is `gh`'s
503
- own — this package reads no token.
543
+ `git` on `PATH`, and `gh` (or `glab` for GitLab) when PR state should count.
544
+ Authentication is the CLI's own — this package reads no token.
504
545
 
505
546
  ## Running from a checkout
506
547
 
package/dist/apply.d.ts CHANGED
@@ -26,9 +26,10 @@ export interface Grant {
26
26
  row: RowRef | null;
27
27
  }
28
28
  export interface ApplyOptions extends HostHooks {
29
- /** Injected runners. Defaults spawn the real `git` and `gh`. */
29
+ /** Injected runners. Defaults spawn the real `git`, `gh` and `glab`. */
30
30
  git?: CommandRunner | undefined;
31
31
  gh?: CommandRunner | undefined;
32
+ glab?: CommandRunner | undefined;
32
33
  /**
33
34
  * Answers lock-owner and working-directory questions, for the re-sweep and
34
35
  * for the checks just before an unlock and a remove. Defaults to `ps` and
package/dist/apply.js CHANGED
@@ -95,13 +95,15 @@ function resweep(repo, scope, opts) {
95
95
  }
96
96
  const sweep = sweepRepo(repo.root, {
97
97
  remote: repo.remote,
98
- noGh: repo.no_gh,
98
+ noProvider: repo.no_gh,
99
+ provider: repo.provider,
99
100
  branch: repo.branch ?? undefined,
100
101
  fetch: opts.fetch ?? repo.fetch,
101
102
  scope,
102
103
  cwd: opts.callerCwd,
103
104
  git: opts.git,
104
105
  gh: opts.gh,
106
+ glab: opts.glab,
105
107
  probe: opts.probe,
106
108
  onProgress: opts.onSweepProgress,
107
109
  });
@@ -25,6 +25,8 @@ export declare function recordPr(facts: Record<string, unknown>, pr: PRInfo | un
25
25
  landed: boolean;
26
26
  gap: PrEvidenceGap | null;
27
27
  };
28
+ /** Why a branch has no PR, naming where the sweep looked. */
29
+ export declare function noPrSentence(repo: RepoInfo): string;
28
30
  /** One sentence on why a merged PR did not count, for `investigationParts`. */
29
31
  export declare function gapSentence(pr: PRInfo, gap: PrEvidenceGap, repo: RepoInfo): string;
30
32
  /**
package/dist/branches.js CHANGED
@@ -53,6 +53,13 @@ export function recordPr(facts, pr, tip, repo) {
53
53
  out['evidence'] = verdict === true ? 'exact-head' : verdict;
54
54
  return verdict === true ? { landed: true, gap: null } : { landed: false, gap: verdict };
55
55
  }
56
+ /** Why a branch has no PR, naming where the sweep looked. */
57
+ export function noPrSentence(repo) {
58
+ const provider = repo.provider;
59
+ if (provider === null)
60
+ return 'No PR lookup (no provider).';
61
+ return `No ${provider.noun} found via \`${provider.listing}\`.`;
62
+ }
56
63
  /** One sentence on why a merged PR did not count, for `investigationParts`. */
57
64
  export function gapSentence(pr, gap, repo) {
58
65
  return gap === 'other-base'
@@ -233,9 +240,7 @@ export function classifyLocalBranch(opts) {
233
240
  : `Branch \`${name}\` is not an ancestor of \`${compareRef}\`.`;
234
241
  const parts = [lead];
235
242
  parts.push(upstream ? `Tracks \`${upstream}\` (${track || 'in sync'}).` : 'Has no upstream.');
236
- parts.push(pr
237
- ? `Associated PR #${pr.number} is ${pr.state} (${pr.url}).`
238
- : 'No PR found via `gh pr list`.');
243
+ parts.push(pr ? `Associated PR #${pr.number} is ${pr.state} (${pr.url}).` : noPrSentence(repo));
239
244
  if (pr && gap)
240
245
  parts.push(gapSentence(pr, gap, repo));
241
246
  const unmatched = patch === null ? null : unmatchedSentence(patch, repo);
package/dist/cli.js CHANGED
@@ -32,6 +32,7 @@ import { VENDOR_DIRS } from "./devlink.js";
32
32
  import { ensureMirror, mirrorProblem } from "./remote-mirror.js";
33
33
  import { ApplyArgumentError, applyPlan, parseGrant, parseRowRef } from "./apply.js";
34
34
  import { PLAN_VERSION, fileStamp, parsePlan, planRepo, readPlan, writePlan, } from "./plan.js";
35
+ import { PROVIDER_CHOICES } from "./providers/index.js";
35
36
  import { RECEIPT_VERSION } from "./receipt.js";
36
37
  import { PLAIN, proposedCommands, renderCountsJson, renderCountsText, renderJson, renderPreview, renderPreviewDrops, renderReceipt, renderStepFailure, renderSummary, renderText, rowsActedOn, summarizeReceipt, TERMINAL_STYLE, } from "./render.js";
37
38
  import { NO_STATUS_LINE, describeStep, describeSweep, statusLine, } from "./status-line.js";
@@ -77,7 +78,13 @@ const SWEEP_FLAGS = {
77
78
  gh: {
78
79
  kind: 'boolean',
79
80
  negatable: true,
80
- help: 'with --remotes: classify on git evidence alone (without --remotes gh is never called)',
81
+ help: 'with --remotes: classify on git evidence alone, the same as --provider none (without --remotes no provider is asked)',
82
+ },
83
+ provider: {
84
+ kind: 'enum',
85
+ choices: PROVIDER_CHOICES,
86
+ valueName: 'name',
87
+ help: 'with --remotes: where pull requests come from: auto (by remote host), github (gh), gitlab (glab), none',
81
88
  },
82
89
  fetch: {
83
90
  kind: 'boolean',
@@ -200,7 +207,7 @@ const applyCli = () => defineCli({
200
207
  output: OUTPUT,
201
208
  },
202
209
  epilogue: `risks: abandon-unmerged, discard-changes, confirm-open-pr
203
- A row that carries a risk you did not --allow is dropped, never deleted.`,
210
+ A row that carries a risk you did not --allow is skipped, never deleted.`,
204
211
  });
205
212
  const terminalConfirm = (question) => (canPrompt() ? askYesNo(question) : null);
206
213
  const PAUSE = new Int32Array(new SharedArrayBuffer(4));
@@ -293,6 +300,10 @@ function sweep(name, v, paths, ctx, status) {
293
300
  if (conflict !== null)
294
301
  return { kind: 'exit', code: usageError(ctx, name, conflict) };
295
302
  }
303
+ if (!v.gh && v.provider !== undefined && v.provider !== 'none') {
304
+ const message = `--no-gh asks no provider; drop it or --provider ${v.provider}`;
305
+ return { kind: 'exit', code: usageError(ctx, name, message) };
306
+ }
296
307
  let branch;
297
308
  try {
298
309
  branch = v.branch === undefined ? undefined : resolveBranch(v.branch, new Git(ctx.cwd));
@@ -306,8 +317,9 @@ function sweep(name, v, paths, ctx, status) {
306
317
  baseDir: ctx.cwd,
307
318
  cwd: ctx.cwd,
308
319
  // Local by default means offline: the fetch and gh go with remote branches.
309
- noGh: !(online && v.gh),
320
+ noProvider: !(online && v.gh) || v.provider === 'none',
310
321
  fetch: online && v.fetch,
322
+ provider: v.provider,
311
323
  branch,
312
324
  // Mid-walk, while the wait is happening — the summary comes too late to
313
325
  // explain why the tool appeared to hang.
@@ -344,7 +356,7 @@ function sweep(name, v, paths, ctx, status) {
344
356
  return { kind: 'exit', code: failure(ctx, new Error(refused)) };
345
357
  if (result.repos.length === 0)
346
358
  return { kind: 'nothing', result };
347
- return { kind: 'ok', result, branch, values: v, scope, noGh: options.noGh === true };
359
+ return { kind: 'ok', result, branch, values: v, scope, noProvider: options.noProvider === true };
348
360
  }
349
361
  /**
350
362
  * Name each repository the sweep could not read, on stderr. True when there
@@ -360,7 +372,8 @@ function reportUnreadable(ctx, name, result) {
360
372
  function planOf(swept, now) {
361
373
  const opts = {
362
374
  remote: swept.values.remote ?? null,
363
- noGh: swept.noGh,
375
+ noProvider: swept.noProvider,
376
+ provider: swept.values.provider,
364
377
  branch: swept.branch ?? null,
365
378
  };
366
379
  return {
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * What the sweep must know about a repository before it can classify anything:
3
3
  * where its main checkout is, what its default branch is called, which remote
4
- * to measure against, and whether `gh` can be asked about pull requests.
4
+ * to measure against, and which provider can be asked about pull requests.
5
5
  */
6
6
  import { type Git } from '@osdlc/typed-git';
7
+ import type { CommandRunner } from '@osdlc/command-seam';
7
8
  import type { Gh } from '@osdlc/typed-gh';
9
+ import { type PrProvider, type ProviderChoice, type ProviderName } from './providers/index.ts';
8
10
  import type { RepoInfo } from './types.ts';
9
11
  /**
10
12
  * Thrown when a path names no repository, or names one whose default branch
@@ -26,7 +28,14 @@ export declare class DiscoveryFailed extends Error {
26
28
  */
27
29
  export declare function discoverRepo(hint: string, opts: {
28
30
  remote?: string | null;
29
- allowGh?: boolean;
31
+ /** False is `provider: 'none'`, whatever `provider` says. */
32
+ allowProvider?: boolean;
33
+ /** Which provider to ask for pull requests. Defaults to `auto`, by the remote's host. */
34
+ provider?: ProviderChoice | undefined;
30
35
  git: Git;
31
36
  gh: Gh;
37
+ /** The `glab` runner for the GitLab provider. Defaults to the real binary. */
38
+ glab?: CommandRunner | undefined;
39
+ /** The providers to choose from. Defaults to {@link providers} over `gh` and `glab`. */
40
+ providers?: Record<ProviderName, PrProvider> | undefined;
32
41
  }): RepoInfo;
package/dist/discover.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * What the sweep must know about a repository before it can classify anything:
3
3
  * where its main checkout is, what its default branch is called, which remote
4
- * to measure against, and whether `gh` can be asked about pull requests.
4
+ * to measure against, and which provider can be asked about pull requests.
5
5
  */
6
6
  import { parseRemoteRepo } from '@osdlc/typed-git';
7
+ import { forgeTarget, probeProvider, providers, } from "./providers/index.js";
7
8
  /**
8
9
  * Thrown when a path names no repository, or names one whose default branch
9
10
  * cannot be determined. A single-repository caller lets it propagate; a
@@ -28,7 +29,7 @@ export class DiscoveryFailed extends Error {
28
29
  export function discoverRepo(hint, opts) {
29
30
  const { git, gh } = opts;
30
31
  const remoteHint = opts.remote ?? null;
31
- const allowGh = opts.allowGh ?? true;
32
+ const allowProvider = opts.allowProvider ?? true;
32
33
  // `topLevel()` answers null for exit 128 only — "this is not a repository",
33
34
  // which is the answer to the question. Any other git failure throws.
34
35
  const top = git.at(hint).topLevel();
@@ -70,14 +71,24 @@ export function discoverRepo(hint, opts) {
70
71
  throw new DiscoveryFailed(`could not determine the default branch of ${root}`);
71
72
  // A short name for the report's header, so a fan-out over long paths stays
72
73
  // readable. It also keys duplicate checkouts.
73
- const slug = remote === null ? null : remoteSlugOf(git.at(root).try.remoteGetUrl(remote));
74
+ const url = remote === null ? null : git.at(root).try.remoteGetUrl(remote);
75
+ const slug = url === null ? null : remoteSlugOf(url);
76
+ const remoteUrl = url?.ok === true && typeof url.value === 'string' ? url.value : null;
74
77
  const currentBranch = git.at(root).headRef() ?? '';
75
- // The two halves separately, not `ready()`: the report says "gh not
76
- // available" and "gh not authenticated" differently, because they send a
77
- // person somewhere different.
78
- const hasGh = allowGh && gh.at(root).installed();
79
- const ghAuthenticated = hasGh && gh.at(root).authenticated();
80
- return { root, slug, mainBranch, remote, currentBranch, hasGh, ghAuthenticated, fetched: false };
78
+ // Installed and signed in are probed separately: "not installed" and "not
79
+ // authenticated" send a person somewhere different.
80
+ const choice = allowProvider ? (opts.provider ?? 'auto') : 'none';
81
+ const target = forgeTarget(remoteUrl);
82
+ const provider = probeProvider(choice, root, target, opts.providers ?? providers({ gh, glab: opts.glab }));
83
+ return {
84
+ root,
85
+ slug,
86
+ mainBranch,
87
+ remote,
88
+ currentBranch,
89
+ provider,
90
+ fetched: false,
91
+ };
81
92
  }
82
93
  /**
83
94
  * `owner/name` for a github.com remote, `host/owner/name` for any other, or
package/dist/index.d.ts CHANGED
@@ -24,6 +24,7 @@ export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan,
24
24
  export { RECEIPT_VERSION, ReceiptSchema, type Dropped, type Receipt, type RowRef, type Step, } from './receipt.ts';
25
25
  export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, type DefaultProbeOptions, type LockOwner, type ProcessHolder, type ProcessProbe, } from './processes.ts';
26
26
  export { fetchPrs } from './prs.ts';
27
+ export { PROVIDER_CHOICES, PROVIDER_NAMES, chooseProvider, forgeTarget, githubProvider, gitlabProvider, probeProvider, providers, type ForgeTarget, type PrProvider, type ProviderChoice, type ProviderName, type ProviderStatus, } from './providers/index.ts';
27
28
  export { expandTilde, isCheckoutRoot, resolveEntries, type Resolution, type ResolveOptions, } from './resolve.ts';
28
29
  export { sweepRepo, sweepRepos, type SweepOptions, type SweepReposOptions } from './sweep.ts';
29
30
  export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, type ApplySummary, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from './render.ts';
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ export { PLAN_VERSION, PlanReadError, planRepo, parsePlan, readPlan, writePlan,
23
23
  export { RECEIPT_VERSION, ReceiptSchema, } from "./receipt.js";
24
24
  export { defaultProcessProbe, isIdleShell, parseLockOwner, parseLsofCwd, } from "./processes.js";
25
25
  export { fetchPrs } from "./prs.js";
26
+ export { PROVIDER_CHOICES, PROVIDER_NAMES, chooseProvider, forgeTarget, githubProvider, gitlabProvider, probeProvider, providers, } from "./providers/index.js";
26
27
  export { expandTilde, isCheckoutRoot, resolveEntries, } from "./resolve.js";
27
28
  export { sweepRepo, sweepRepos } from "./sweep.js";
28
29
  export { proposedCommands, renderJson, renderApplySummary, renderReceipt, summarizeReceipt, renderRepo, renderScan, renderSummary, renderText, requiresNote, } from "./render.js";
package/dist/plan.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * whose fingerprint no longer matches.
5
5
  */
6
6
  import { z } from 'zod';
7
+ import { type ProviderChoice } from './providers/types.ts';
7
8
  import { type SweepItem } from './types.ts';
8
9
  export declare const PLAN_VERSION = 1;
9
10
  /** The categories a plan can hold, in the order apply runs them. */
@@ -28,6 +29,12 @@ export declare const PlanRepoSchema: z.ZodObject<{
28
29
  root: z.ZodString;
29
30
  remote: z.ZodNullable<z.ZodString>;
30
31
  no_gh: z.ZodBoolean;
32
+ provider: z.ZodOptional<z.ZodEnum<{
33
+ github: "github";
34
+ gitlab: "gitlab";
35
+ auto: "auto";
36
+ none: "none";
37
+ }>>;
31
38
  branch: z.ZodDefault<z.ZodNullable<z.ZodString>>;
32
39
  fetch: z.ZodDefault<z.ZodBoolean>;
33
40
  rows: z.ZodArray<z.ZodObject<{
@@ -53,6 +60,12 @@ export declare const PlanSchema: z.ZodObject<{
53
60
  root: z.ZodString;
54
61
  remote: z.ZodNullable<z.ZodString>;
55
62
  no_gh: z.ZodBoolean;
63
+ provider: z.ZodOptional<z.ZodEnum<{
64
+ github: "github";
65
+ gitlab: "gitlab";
66
+ auto: "auto";
67
+ none: "none";
68
+ }>>;
56
69
  branch: z.ZodDefault<z.ZodNullable<z.ZodString>>;
57
70
  fetch: z.ZodDefault<z.ZodBoolean>;
58
71
  rows: z.ZodArray<z.ZodObject<{
@@ -89,9 +102,10 @@ export declare function sameFingerprint(a: Record<string, unknown>, b: Record<st
89
102
  /** One repository's plan entry: its deletable rows. */
90
103
  export declare function planRepo(root: string, opts: {
91
104
  remote: string | null;
92
- noGh: boolean;
105
+ noProvider: boolean;
93
106
  branch?: string | null;
94
107
  fetch?: boolean;
108
+ provider?: ProviderChoice | undefined;
95
109
  }, items: readonly PlannableRow[]): PlanRepo;
96
110
  /** A UTC timestamp safe in a file name, e.g. `2026-10-04T12-30-05-123Z`. */
97
111
  export declare function fileStamp(d: Date): string;
package/dist/plan.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { z } from 'zod';
8
+ import { PROVIDER_CHOICES } from "./providers/types.js";
8
9
  import { AUTHORIZATIONS } from "./types.js";
9
10
  import { writeJsonAtomic } from "./write-json.js";
10
11
  export const PLAN_VERSION = 1;
@@ -20,7 +21,14 @@ export const PlanRepoSchema = z.object({
20
21
  root: z.string(),
21
22
  /** The `remote` the sweep ran with; the re-sweep uses the same. */
22
23
  remote: z.string().nullable(),
24
+ /**
25
+ * True when the sweep asked no provider for pull requests. The name is the
26
+ * plan format's, from when GitHub was the only provider; renaming it would
27
+ * break every plan already written.
28
+ */
23
29
  no_gh: z.boolean(),
30
+ /** The provider choice the sweep ran with; absent is `auto`. `no_gh` wins over it. */
31
+ provider: z.enum(PROVIDER_CHOICES).optional(),
24
32
  /** The `branch` the sweep was narrowed to; the re-sweep uses the same. */
25
33
  branch: z.string().nullable().default(null),
26
34
  /**
@@ -87,7 +95,8 @@ export function planRepo(root, opts, items) {
87
95
  return {
88
96
  root,
89
97
  remote: opts.remote,
90
- no_gh: opts.noGh,
98
+ no_gh: opts.noProvider,
99
+ ...(opts.provider === undefined || opts.provider === 'auto' ? {} : { provider: opts.provider }),
91
100
  branch: opts.branch ?? null,
92
101
  fetch: opts.fetch ?? false,
93
102
  rows,
@@ -0,0 +1,10 @@
1
+ /** Pull requests from GitHub, through `gh`. */
2
+ import type { Gh } from '@osdlc/typed-gh';
3
+ import type { PrProvider } from './types.ts';
4
+ /** The most PRs one listing reads. */
5
+ export declare const PR_LIMIT = 300;
6
+ /**
7
+ * A provider over `gh pr list`. `gh` picks the repository from the checkout's
8
+ * remotes, so `target` is not consulted.
9
+ */
10
+ export declare function githubProvider(gh: Gh): PrProvider;
@@ -0,0 +1,56 @@
1
+ /** Pull requests from GitHub, through `gh`. */
2
+ /** The fields classification consults. Asking for more costs latency. */
3
+ const FIELDS = [
4
+ 'number',
5
+ 'state',
6
+ 'mergedAt',
7
+ 'headRefName',
8
+ 'headRefOid',
9
+ 'baseRefName',
10
+ 'isCrossRepository',
11
+ 'title',
12
+ 'url',
13
+ ];
14
+ /** The most PRs one listing reads. */
15
+ export const PR_LIMIT = 300;
16
+ /**
17
+ * A provider over `gh pr list`. `gh` picks the repository from the checkout's
18
+ * remotes, so `target` is not consulted.
19
+ */
20
+ export function githubProvider(gh) {
21
+ return {
22
+ name: 'github',
23
+ words: { tool: 'gh', noun: 'PR', listing: 'gh pr list' },
24
+ probe(root) {
25
+ const available = gh.at(root).installed();
26
+ return { available, authenticated: available && gh.at(root).authenticated() };
27
+ },
28
+ listPrs(root, _target, branch) {
29
+ const rows = gh.at(root).prList({
30
+ state: 'all',
31
+ limit: PR_LIMIT,
32
+ fields: FIELDS,
33
+ head: branch,
34
+ });
35
+ if (rows === null)
36
+ return null;
37
+ const prs = [];
38
+ for (const row of rows.rows) {
39
+ // A fork's head branch is not this repository's branch, whatever its name.
40
+ if (!row.headRefName || row.isCrossRepository)
41
+ continue;
42
+ prs.push({
43
+ number: row.number,
44
+ state: row.state,
45
+ mergedAt: row.mergedAt,
46
+ headRef: row.headRefName,
47
+ headOid: row.headRefOid,
48
+ baseRef: row.baseRefName,
49
+ title: row.title,
50
+ url: row.url,
51
+ });
52
+ }
53
+ return prs;
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Merge requests from GitLab, through `glab api`.
3
+ *
4
+ * The REST listing, not `glab mr list`: it carries the source and target
5
+ * project ids, which is how a fork's merge request is told apart, and it takes
6
+ * an explicit host and project, so a checkout with several remotes is read
7
+ * against the one the sweep measures.
8
+ */
9
+ import { type CommandRunner } from '@osdlc/command-seam';
10
+ import type { PRInfo } from '../types.ts';
11
+ import type { PrProvider } from './types.ts';
12
+ /** The fields of a merge request this reads. */
13
+ interface MergeRequest {
14
+ iid: number;
15
+ state: string;
16
+ source_branch: string;
17
+ target_branch: string;
18
+ sha?: string | null;
19
+ merged_at?: string | null;
20
+ web_url?: string;
21
+ title?: string;
22
+ source_project_id: number;
23
+ target_project_id: number;
24
+ }
25
+ /** One merge request as a {@link PRInfo}, or null for a fork's or an unknown state's. */
26
+ export declare function mergeRequestToPr(mr: MergeRequest): PRInfo | null;
27
+ /**
28
+ * A provider over `glab`. It needs `target.project`: without one there is
29
+ * nothing to ask, and the listing answers null.
30
+ */
31
+ export declare function gitlabProvider(glab: CommandRunner): PrProvider;
32
+ export {};
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Merge requests from GitLab, through `glab api`.
3
+ *
4
+ * The REST listing, not `glab mr list`: it carries the source and target
5
+ * project ids, which is how a fork's merge request is told apart, and it takes
6
+ * an explicit host and project, so a checkout with several remotes is read
7
+ * against the one the sweep measures.
8
+ */
9
+ import { parseJsonResult, runFailed, } from '@osdlc/command-seam';
10
+ import { PR_LIMIT } from "./github.js";
11
+ const PER_PAGE = 100;
12
+ /** GitLab's states, as the GitHub names classification reads. A locked MR is an open one mid-update. */
13
+ const STATE = {
14
+ opened: 'OPEN',
15
+ locked: 'OPEN',
16
+ merged: 'MERGED',
17
+ closed: 'CLOSED',
18
+ };
19
+ /** Absent, or of the given type (null counting where `nullable`). */
20
+ function optional(value, type, nullable = false) {
21
+ return value === undefined || (nullable && value === null) || typeof value === type;
22
+ }
23
+ function isMergeRequest(value) {
24
+ if (typeof value !== 'object' || value === null)
25
+ return false;
26
+ const mr = value;
27
+ return (optional(mr['sha'], 'string', true) &&
28
+ optional(mr['merged_at'], 'string', true) &&
29
+ optional(mr['web_url'], 'string') &&
30
+ optional(mr['title'], 'string') &&
31
+ typeof mr['iid'] === 'number' &&
32
+ typeof mr['state'] === 'string' &&
33
+ typeof mr['source_branch'] === 'string' &&
34
+ typeof mr['target_branch'] === 'string' &&
35
+ typeof mr['source_project_id'] === 'number' &&
36
+ typeof mr['target_project_id'] === 'number');
37
+ }
38
+ /** One merge request as a {@link PRInfo}, or null for a fork's or an unknown state's. */
39
+ export function mergeRequestToPr(mr) {
40
+ // A fork's source branch is not this repository's branch, whatever its name.
41
+ if (mr.source_project_id !== mr.target_project_id)
42
+ return null;
43
+ const state = STATE[mr.state];
44
+ if (state === undefined || mr.source_branch === '')
45
+ return null;
46
+ return {
47
+ number: mr.iid,
48
+ state,
49
+ mergedAt: mr.merged_at ?? null,
50
+ headRef: mr.source_branch,
51
+ headOid: typeof mr.sha === 'string' && mr.sha !== '' ? mr.sha : null,
52
+ baseRef: mr.target_branch,
53
+ title: mr.title ?? '',
54
+ url: mr.web_url ?? '',
55
+ };
56
+ }
57
+ /** Exited 0 with all its output captured. */
58
+ function succeeded(result) {
59
+ return !runFailed(result);
60
+ }
61
+ /**
62
+ * A provider over `glab`. It needs `target.project`: without one there is
63
+ * nothing to ask, and the listing answers null.
64
+ */
65
+ export function gitlabProvider(glab) {
66
+ const run = (root, argv) => glab.run(argv, { cwd: root });
67
+ const hostArgs = (host) => (host === null ? [] : ['--hostname', host]);
68
+ return {
69
+ name: 'gitlab',
70
+ words: { tool: 'glab', noun: 'merge request', listing: 'glab api' },
71
+ probe(root, target) {
72
+ const available = succeeded(run(root, ['--version']));
73
+ const authenticated = available && succeeded(run(root, ['auth', 'status', ...hostArgs(target.host)]));
74
+ return { available, authenticated };
75
+ },
76
+ listPrs(root, target, branch) {
77
+ if (target.project === null)
78
+ return null;
79
+ const project = encodeURIComponent(target.project);
80
+ const prs = [];
81
+ for (let page = 1; (page - 1) * PER_PAGE < PR_LIMIT; page++) {
82
+ const query = new URLSearchParams({
83
+ state: 'all',
84
+ per_page: String(PER_PAGE),
85
+ page: String(page),
86
+ order_by: 'updated_at',
87
+ sort: 'desc',
88
+ });
89
+ if (branch !== undefined)
90
+ query.set('source_branch', branch);
91
+ const result = run(root, [
92
+ 'api',
93
+ ...hostArgs(target.host),
94
+ `projects/${project}/merge_requests?${query.toString()}`,
95
+ ]);
96
+ // A failed, cut-short or unreadable page fails the listing: a missing
97
+ // open MR would read as no MR.
98
+ if (!succeeded(result))
99
+ return null;
100
+ const listed = parseJsonResult(result);
101
+ if (!listed.ok || !Array.isArray(listed.value))
102
+ return null;
103
+ const rows = listed.value;
104
+ for (const row of rows) {
105
+ if (!isMergeRequest(row))
106
+ continue;
107
+ const pr = mergeRequestToPr(row);
108
+ if (pr !== null)
109
+ prs.push(pr);
110
+ }
111
+ if (rows.length < PER_PAGE)
112
+ break;
113
+ }
114
+ return prs;
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Choosing a provider for a repository, from the caller's choice and the
3
+ * remote's host.
4
+ */
5
+ import { type CommandRunner } from '@osdlc/command-seam';
6
+ import type { Gh } from '@osdlc/typed-gh';
7
+ import type { ForgeTarget, PrProvider, ProviderChoice, ProviderName, ProviderStatus } from './types.ts';
8
+ export { githubProvider } from './github.ts';
9
+ export { gitlabProvider } from './gitlab.ts';
10
+ export * from './types.ts';
11
+ /** Every known provider, over the given clients. `glab` defaults to the real binary. */
12
+ export declare function providers(clients: {
13
+ gh: Gh;
14
+ glab?: CommandRunner | undefined;
15
+ }): Record<ProviderName, PrProvider>;
16
+ /**
17
+ * The host and full project path a remote URL names, groups kept
18
+ * (`gitlab.com:group/sub/name.git` is project `group/sub/name`). A local path
19
+ * or a `file:` URL names no forge.
20
+ */
21
+ export declare function forgeTarget(url: string | null): ForgeTarget;
22
+ /**
23
+ * The provider `choice` names for `target`. `auto` reads the host: one with
24
+ * `gitlab` in its name is GitLab, any other is GitHub, which is what the sweep
25
+ * did before providers existed (`gh` answers nothing for a host it does not
26
+ * know). `none` is no provider.
27
+ */
28
+ export declare function chooseProvider(choice: ProviderChoice, target: ForgeTarget): ProviderName | null;
29
+ /** The chosen provider for a repository, probed; null for none. */
30
+ export declare function probeProvider(choice: ProviderChoice, root: string, target: ForgeTarget, all: Record<ProviderName, PrProvider>): ProviderStatus | null;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Choosing a provider for a repository, from the caller's choice and the
3
+ * remote's host.
4
+ */
5
+ import { resolveRunner } from '@osdlc/command-seam';
6
+ import { githubProvider } from "./github.js";
7
+ import { gitlabProvider } from "./gitlab.js";
8
+ export { githubProvider } from "./github.js";
9
+ export { gitlabProvider } from "./gitlab.js";
10
+ export * from "./types.js";
11
+ /** Every known provider, over the given clients. `glab` defaults to the real binary. */
12
+ export function providers(clients) {
13
+ return {
14
+ github: githubProvider(clients.gh),
15
+ gitlab: gitlabProvider(resolveRunner('glab', clients.glab)),
16
+ };
17
+ }
18
+ const NO_TARGET = { host: null, project: null };
19
+ /**
20
+ * The host and full project path a remote URL names, groups kept
21
+ * (`gitlab.com:group/sub/name.git` is project `group/sub/name`). A local path
22
+ * or a `file:` URL names no forge.
23
+ */
24
+ export function forgeTarget(url) {
25
+ if (url === null)
26
+ return NO_TARGET;
27
+ const trimmed = url.trim();
28
+ if (/^file:/i.test(trimmed))
29
+ return NO_TARGET;
30
+ // scp form, user optional (`host:path` is valid git). A one-letter host is a
31
+ // Windows drive (`C:/repo`), which git reads as a local path.
32
+ const scp = /^(?:[\w.-]+@)?([^:/\\]{2,}):(?!\/\/)(.+)$/.exec(trimmed);
33
+ const uri = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(trimmed);
34
+ const match = uri ?? scp;
35
+ if (match === null)
36
+ return NO_TARGET;
37
+ const path = match[2].replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
38
+ const parts = path.split('/').filter((p) => p !== '');
39
+ if (parts.length < 2)
40
+ return { host: match[1].toLowerCase(), project: null };
41
+ return { host: match[1].toLowerCase(), project: parts.join('/') };
42
+ }
43
+ /**
44
+ * The provider `choice` names for `target`. `auto` reads the host: one with
45
+ * `gitlab` in its name is GitLab, any other is GitHub, which is what the sweep
46
+ * did before providers existed (`gh` answers nothing for a host it does not
47
+ * know). `none` is no provider.
48
+ */
49
+ export function chooseProvider(choice, target) {
50
+ if (choice === 'none')
51
+ return null;
52
+ if (choice !== 'auto')
53
+ return choice;
54
+ return target.host !== null && target.host.includes('gitlab') ? 'gitlab' : 'github';
55
+ }
56
+ /** The chosen provider for a repository, probed; null for none. */
57
+ export function probeProvider(choice, root, target, all) {
58
+ const name = chooseProvider(choice, target);
59
+ if (name === null)
60
+ return null;
61
+ const provider = all[name];
62
+ return { name, ...provider.words, ...target, ...provider.probe(root, target) };
63
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Where pull request evidence comes from. A provider answers two questions
3
+ * for a repository: can its CLI be asked at all, and which pull (or merge)
4
+ * requests exist. Classification reads {@link PRInfo} and never knows which
5
+ * provider answered.
6
+ */
7
+ import type { PRInfo } from '../types.ts';
8
+ /** The providers this build knows. */
9
+ export declare const PROVIDER_NAMES: readonly ["github", "gitlab"];
10
+ export type ProviderName = (typeof PROVIDER_NAMES)[number];
11
+ /** What a caller may ask for: one provider, none, or the one the remote's host suggests. */
12
+ export declare const PROVIDER_CHOICES: readonly ["auto", "github", "gitlab", "none"];
13
+ export type ProviderChoice = (typeof PROVIDER_CHOICES)[number];
14
+ /** The forge a remote points at, read off its URL. */
15
+ export interface ForgeTarget {
16
+ /** The host, lower-cased, without a port; null when the remote names none. */
17
+ host: string | null;
18
+ /** The full project path, groups included (`group/sub/name`); null when there is none. */
19
+ project: string | null;
20
+ }
21
+ /** How a report names a provider's requests and the lookup that lists them. */
22
+ export interface ProviderWords {
23
+ /** The CLI the provider runs: `gh`, `glab`. */
24
+ tool: string;
25
+ /** What the forge calls one request: `PR`, `merge request`. */
26
+ noun: string;
27
+ /** The command that lists them, as a report quotes it: `gh pr list`. */
28
+ listing: string;
29
+ }
30
+ /** The provider chosen for a repository, and what its probe found. */
31
+ export interface ProviderStatus extends ForgeTarget, ProviderWords {
32
+ name: ProviderName;
33
+ /** The CLI is installed. */
34
+ available: boolean;
35
+ /** The CLI has credentials for `host`. */
36
+ authenticated: boolean;
37
+ }
38
+ /** One source of pull request evidence. */
39
+ export interface PrProvider {
40
+ readonly name: ProviderName;
41
+ readonly words: ProviderWords;
42
+ /** Whether the CLI is installed and signed in for `target`'s host. */
43
+ probe(root: string, target: ForgeTarget): {
44
+ available: boolean;
45
+ authenticated: boolean;
46
+ };
47
+ /**
48
+ * The repository's pull requests, fork PRs already dropped, in any order;
49
+ * null when the listing failed. A failure is not "no PRs": the caller
50
+ * classifies on git evidence alone.
51
+ *
52
+ * @param branch Narrow the listing to this exact head branch. The caller
53
+ * checks the head again, so a provider that cannot narrow may list all.
54
+ */
55
+ listPrs(root: string, target: ForgeTarget, branch?: string): PRInfo[] | null;
56
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Where pull request evidence comes from. A provider answers two questions
3
+ * for a repository: can its CLI be asked at all, and which pull (or merge)
4
+ * requests exist. Classification reads {@link PRInfo} and never knows which
5
+ * provider answered.
6
+ */
7
+ /** The providers this build knows. */
8
+ export const PROVIDER_NAMES = ['github', 'gitlab'];
9
+ /** What a caller may ask for: one provider, none, or the one the remote's host suggests. */
10
+ export const PROVIDER_CHOICES = ['auto', ...PROVIDER_NAMES, 'none'];
package/dist/prs.d.ts CHANGED
@@ -1,26 +1,30 @@
1
1
  /**
2
2
  * The pull requests of a repository, indexed by head branch.
3
3
  *
4
- * One `gh pr list` per repository, not one query per branch: a repository with
4
+ * One listing per repository, not one query per branch: a repository with
5
5
  * sixty branches would otherwise pay sixty round trips to answer a question
6
- * one listing covers.
6
+ * one listing covers. Which forge answers is the provider's business; see
7
+ * `providers/`.
7
8
  */
8
- import type { Gh } from '@osdlc/typed-gh';
9
+ import type { PrProvider } from './providers/index.ts';
9
10
  import type { PRInfo, RepoInfo } from './types.ts';
10
11
  /**
11
12
  * Every same-repository pull request, keyed by head branch. Fork PRs are
12
- * dropped: a fork branch that shares a name proves nothing about ours.
13
+ * dropped by the provider: a fork branch that shares a name proves nothing
14
+ * about ours.
13
15
  *
14
16
  * A branch can carry several PRs over its life. The one that decides a
15
17
  * verdict is the most live: an open PR means keep whatever else happened, and
16
18
  * a merged one means the work landed. Ties go to the higher number, so two
17
19
  * runs over unchanged state agree.
18
20
  *
19
- * An empty map is the honest answer when `gh` is missing, unauthenticated or
20
- * failing. Classification degrades to git evidence and the report says so.
21
+ * An empty map is the honest answer when the provider's CLI is missing,
22
+ * unauthenticated or failing, or when there is no provider. Classification
23
+ * degrades to git evidence and the report says so.
21
24
  *
22
- * @param headPrefix Ask `gh` only for PRs whose head starts with this; the map is
23
- * still keyed by exact head. typed-gh's `head` is exact, so the prefix goes
24
- * through gh's `head:` search, which matches by prefix, and is checked again here.
25
+ * @param provider The provider to ask. It is asked only when it is the one
26
+ * `repo.provider` names and that probe found it signed in.
27
+ * @param branch Ask only for PRs whose head is exactly this branch; rows
28
+ * for any other head are dropped here too.
25
29
  */
26
- export declare function fetchPrs(repo: RepoInfo, gh: Gh, headPrefix?: string): Map<string, PRInfo>;
30
+ export declare function fetchPrs(repo: RepoInfo, provider: PrProvider, branch?: string): Map<string, PRInfo>;
package/dist/prs.js CHANGED
@@ -1,72 +1,48 @@
1
1
  /**
2
2
  * The pull requests of a repository, indexed by head branch.
3
3
  *
4
- * One `gh pr list` per repository, not one query per branch: a repository with
4
+ * One listing per repository, not one query per branch: a repository with
5
5
  * sixty branches would otherwise pay sixty round trips to answer a question
6
- * one listing covers.
6
+ * one listing covers. Which forge answers is the provider's business; see
7
+ * `providers/`.
7
8
  */
8
- /** The fields classification consults. Asking for more costs latency. */
9
- const FIELDS = [
10
- 'number',
11
- 'state',
12
- 'mergedAt',
13
- 'headRefName',
14
- 'headRefOid',
15
- 'baseRefName',
16
- 'isCrossRepository',
17
- 'title',
18
- 'url',
19
- ];
20
- const LIMIT = 300;
21
9
  /** Open outranks merged outranks closed, and a newer PR outranks an older. */
22
10
  function priority(state) {
23
11
  return { OPEN: 3, MERGED: 2, CLOSED: 1 }[state] ?? 0;
24
12
  }
25
13
  /**
26
14
  * Every same-repository pull request, keyed by head branch. Fork PRs are
27
- * dropped: a fork branch that shares a name proves nothing about ours.
15
+ * dropped by the provider: a fork branch that shares a name proves nothing
16
+ * about ours.
28
17
  *
29
18
  * A branch can carry several PRs over its life. The one that decides a
30
19
  * verdict is the most live: an open PR means keep whatever else happened, and
31
20
  * a merged one means the work landed. Ties go to the higher number, so two
32
21
  * runs over unchanged state agree.
33
22
  *
34
- * An empty map is the honest answer when `gh` is missing, unauthenticated or
35
- * failing. Classification degrades to git evidence and the report says so.
23
+ * An empty map is the honest answer when the provider's CLI is missing,
24
+ * unauthenticated or failing, or when there is no provider. Classification
25
+ * degrades to git evidence and the report says so.
36
26
  *
37
- * @param headPrefix Ask `gh` only for PRs whose head starts with this; the map is
38
- * still keyed by exact head. typed-gh's `head` is exact, so the prefix goes
39
- * through gh's `head:` search, which matches by prefix, and is checked again here.
27
+ * @param provider The provider to ask. It is asked only when it is the one
28
+ * `repo.provider` names and that probe found it signed in.
29
+ * @param branch Ask only for PRs whose head is exactly this branch; rows
30
+ * for any other head are dropped here too.
40
31
  */
41
- export function fetchPrs(repo, gh, headPrefix) {
32
+ export function fetchPrs(repo, provider, branch) {
42
33
  const byHead = new Map();
43
- if (!(repo.hasGh && repo.ghAuthenticated))
34
+ const status = repo.provider;
35
+ if (status === null || status.name !== provider.name || !status.authenticated)
44
36
  return byHead;
45
- const rows = gh.at(repo.root).prList({
46
- state: 'all',
47
- limit: LIMIT,
48
- fields: FIELDS,
49
- search: headPrefix === undefined ? undefined : `head:${headPrefix}`,
50
- });
37
+ const rows = provider.listPrs(repo.root, status, branch);
51
38
  if (rows === null)
52
39
  return byHead;
53
- for (const row of rows.rows) {
54
- const head = row.headRefName;
55
- // A fork's head branch is not this repository's branch, whatever its name.
56
- if (!head || row.isCrossRepository)
40
+ for (const info of rows) {
41
+ const head = info.headRef;
42
+ if (!head)
57
43
  continue;
58
- if (headPrefix !== undefined && !head.startsWith(headPrefix))
44
+ if (branch !== undefined && head !== branch)
59
45
  continue;
60
- const info = {
61
- number: row.number,
62
- state: row.state,
63
- mergedAt: row.mergedAt,
64
- headRef: head,
65
- headOid: row.headRefOid,
66
- baseRef: row.baseRefName,
67
- title: row.title,
68
- url: row.url,
69
- };
70
46
  const existing = byHead.get(head);
71
47
  if (existing === undefined) {
72
48
  byHead.set(head, info);
package/dist/render.d.ts CHANGED
@@ -94,7 +94,7 @@ export interface ApplySummary {
94
94
  export declare function summarizeReceipt(receipt: Receipt): ApplySummary;
95
95
  /** Rows a receipt deletes or prunes, which is what a person is asked to approve. */
96
96
  export declare function rowsActedOn(summary: ApplySummary): number;
97
- /** The closing lines of an apply run: what it deleted, dropped and could not finish. */
97
+ /** The closing lines of an apply run: what it deleted, skipped and could not finish. */
98
98
  export declare function renderApplySummary(receipt: Receipt): string[];
99
99
  /** The word a preview shows for a step: what it does to the row. */
100
100
  export declare const STEP_VERB: Record<Step['action'], string>;
package/dist/render.js CHANGED
@@ -352,7 +352,27 @@ export function rowsActedOn(summary) {
352
352
  return d.worktree + d.local_branch + d.remote_branch + summary.pruned;
353
353
  }
354
354
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
355
- /** The closing lines of an apply run: what it deleted, dropped and could not finish. */
355
+ /**
356
+ * How to include a skipped row next time, for the reasons a person can act
357
+ * on. A `not-authorized` detail is `needs <risk>, <risk>` (see `recheck`).
358
+ */
359
+ function dropHints(receipt) {
360
+ const risks = new Set();
361
+ for (const d of receipt.dropped) {
362
+ if (d.reason !== 'not-authorized')
363
+ continue;
364
+ for (const risk of d.detail.replace(/^needs /, '').split(', '))
365
+ if (risk)
366
+ risks.add(risk);
367
+ }
368
+ return {
369
+ 'needs-review': 'name with --row',
370
+ ...(risks.size > 0
371
+ ? { 'not-authorized': [...risks].map((r) => `--allow ${r}`).join(', ') }
372
+ : {}),
373
+ };
374
+ }
375
+ /** The closing lines of an apply run: what it deleted, skipped and could not finish. */
356
376
  export function renderApplySummary(receipt) {
357
377
  const s = summarizeReceipt(receipt);
358
378
  const dry = receipt.status === 'dry-run';
@@ -372,7 +392,12 @@ export function renderApplySummary(receipt) {
372
392
  const dropped = Object.entries(s.dropped);
373
393
  if (dropped.length > 0) {
374
394
  const total = dropped.reduce((n, [, c]) => n + c, 0);
375
- lines.push(`dropped ${total}: ${dropped.map(([reason, c]) => `${c} ${reason}`).join(', ')}`);
395
+ const hints = dropHints(receipt);
396
+ const counts = dropped.map(([reason, c]) => {
397
+ const hint = hints[reason];
398
+ return hint === undefined ? `${c} ${reason}` : `${c} ${reason} (${hint})`;
399
+ });
400
+ lines.push(`skipped ${total}, not deleted: ${counts.join(', ')}`);
376
401
  }
377
402
  if (s.failed > 0) {
378
403
  lines.push(`stopped at a failed step; ${plural(s.notRun, 'row', 'rows')} not run`);
@@ -421,7 +446,9 @@ export function renderPreview(receipt, style = PLAIN) {
421
446
  for (const s of receipt.steps.filter((s) => s.repo === repo)) {
422
447
  const verb = STEP_VERB[s.action];
423
448
  const color = actionStyle(style, verb === 'unlock' ? 'review' : verb);
424
- lines.push(s.row === null ? ` ${color(verb)}` : ` ${color(pad(verb, 6))} ${row(s.row)}`);
449
+ lines.push(s.row === null
450
+ ? ` ${color(pad(verb, 6))} worktree records`
451
+ : ` ${color(pad(verb, 6))} ${row(s.row)}`);
425
452
  lines.push(` ${style.muted(stripControls(s.command))}`);
426
453
  }
427
454
  for (const d of receipt.dropped.filter((d) => d.repo === repo)) {
@@ -456,7 +483,7 @@ export function renderReceipt(receipt, receiptPath, style = PLAIN, opts = {}) {
456
483
  // A run shown live has printed its dropped rows and steps already.
457
484
  for (const d of opts.steps === false ? [] : receipt.dropped) {
458
485
  const shown = stripControls(`${d.category}:${d.id} — ${d.reason} (${d.detail})`);
459
- lines.push(`${style.review('dropped ')}${shown}`);
486
+ lines.push(`${style.review('skipped ')}${shown}`);
460
487
  }
461
488
  for (const s of opts.steps === false ? [] : receipt.steps) {
462
489
  const what = s.row === null ? s.command : `${s.row.category}:${s.row.id} — ${s.command}`;
@@ -31,7 +31,10 @@ export function describeSweep(p, home = homedir()) {
31
31
  const count = p.done === undefined || p.total === undefined
32
32
  ? ''
33
33
  : ` ${progressBar(p.done, p.total)} ${p.done + 1}/${p.total}`;
34
- return `${where}${STEP_TEXT[p.step]}${count} · ${tilde(p.repo, home)}`;
34
+ const text = p.step === 'prs' && p.tool !== undefined
35
+ ? `listing pull requests (${p.tool})`
36
+ : STEP_TEXT[p.step];
37
+ return `${where}${text}${count} · ${tilde(p.repo, home)}`;
35
38
  }
36
39
  /** An apply step as one line, e.g. `deleting ██████░░░░ 3/7: delete-remote origin/feat/x`. */
37
40
  export function describeStep(index, total, step) {
@@ -13,8 +13,9 @@ const BAR = 10;
13
13
  const TOTAL_BAR = 30;
14
14
  function label(step) {
15
15
  const verb = STEP_VERB[step.action];
16
+ // The closing prune belongs to no row: it clears the repository's stale worktree records.
16
17
  if (step.row === null)
17
- return verb;
18
+ return stripControls(`${verb.padEnd(6)} worktree records in ${step.repo}`);
18
19
  return stripControls(`${verb.padEnd(6)} ${step.row.category} ${step.row.id}`);
19
20
  }
20
21
  /**
package/dist/sweep.d.ts CHANGED
@@ -7,14 +7,20 @@
7
7
  */
8
8
  import type { CommandRunner } from '@osdlc/command-seam';
9
9
  import { type ProcessProbe } from './processes.ts';
10
+ import { type ProviderChoice } from './providers/index.ts';
10
11
  import { type RepoSweep, type Scope, type SweepProgress, type SweepResult } from './types.ts';
11
12
  export interface SweepOptions {
12
13
  /** Which categories to gather. Defaults to all of them. */
13
14
  scope?: Iterable<Scope | string>;
14
15
  /** Remote to measure against. Defaults to `origin`, then the first one. */
15
16
  remote?: string | null;
16
- /** Skip `gh` entirely and classify on git evidence alone. */
17
- noGh?: boolean;
17
+ /** Ask no provider and classify on git evidence alone. Same as `provider: 'none'`, and wins over it. */
18
+ noProvider?: boolean;
19
+ /**
20
+ * Which provider to ask for pull requests: `github` (`gh`), `gitlab`
21
+ * (`glab`), `none`, or `auto`, which reads the remote's host. Defaults to `auto`.
22
+ */
23
+ provider?: ProviderChoice | undefined;
18
24
  /**
19
25
  * Run `git fetch --prune <remote>` before classifying, so ancestry is read
20
26
  * against fresh remote-tracking refs. A failed fetch makes the repository
@@ -34,9 +40,10 @@ export interface SweepOptions {
34
40
  * when that worktree is blocked or cannot be removed.
35
41
  */
36
42
  branch?: string | undefined;
37
- /** Injected runners. Defaults spawn the real `git` and `gh`. */
43
+ /** Injected runners. Defaults spawn the real `git`, `gh` and `glab`. */
38
44
  git?: CommandRunner | undefined;
39
45
  gh?: CommandRunner | undefined;
46
+ glab?: CommandRunner | undefined;
40
47
  /**
41
48
  * Answers whether a worktree lock's owner is alive and which processes work
42
49
  * inside a worktree. Defaults to {@link defaultProcessProbe}, one per
package/dist/sweep.js CHANGED
@@ -11,6 +11,7 @@ import { gatherLocalBranches, gatherRemoteBranches } from "./branches.js";
11
11
  import { DiscoveryFailed, discoverRepo } from "./discover.js";
12
12
  import { defaultProcessProbe } from "./processes.js";
13
13
  import { fetchPrs } from "./prs.js";
14
+ import { providers } from "./providers/index.js";
14
15
  import { resolveEntries } from "./resolve.js";
15
16
  import { ALL_SCOPES, findDuplicateCheckouts, } from "./types.js";
16
17
  import { gatherWorktrees } from "./worktrees.js";
@@ -33,13 +34,15 @@ function clients(path, opts) {
33
34
  export function sweepRepo(path, opts = {}) {
34
35
  const scope = scopeSet(opts.scope);
35
36
  const { git, gh } = clients(path, opts);
37
+ const known = providers({ gh, glab: opts.glab });
36
38
  const only = opts.branch;
37
39
  const at = opts.position ?? { index: 1, of: 1 };
38
- const report = (step, done, total) => opts.onProgress?.({
40
+ const report = (step, done, total, tool) => opts.onProgress?.({
39
41
  repo: path,
40
42
  ...at,
41
43
  step,
42
44
  ...(done === undefined || total === undefined ? {} : { done, total }),
45
+ ...(tool === undefined ? {} : { tool }),
43
46
  });
44
47
  const ticker = (step) => (done, total) => report(step, done, total);
45
48
  report('discover');
@@ -47,9 +50,11 @@ export function sweepRepo(path, opts = {}) {
47
50
  try {
48
51
  repo = discoverRepo(path, {
49
52
  remote: opts.remote ?? null,
50
- allowGh: opts.noGh !== true,
53
+ allowProvider: opts.noProvider !== true,
54
+ provider: opts.provider,
51
55
  git,
52
56
  gh,
57
+ providers: known,
53
58
  });
54
59
  }
55
60
  catch (err) {
@@ -77,9 +82,10 @@ export function sweepRepo(path, opts = {}) {
77
82
  repo = { ...repo, fetched: true };
78
83
  }
79
84
  try {
80
- if (repo.hasGh && repo.ghAuthenticated)
81
- report('prs');
82
- const prs = fetchPrs(repo, gh, only);
85
+ const status = repo.provider ?? null;
86
+ if (status?.authenticated === true)
87
+ report('prs', undefined, undefined, status.tool);
88
+ const prs = status === null ? new Map() : fetchPrs(repo, known[status.name], only);
83
89
  const items = [];
84
90
  // Worktrees are gathered whenever local branches are, even if they are not
85
91
  // reported: a branch checked out in one must not be judged on its own.
package/dist/types.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * by `category` rather than reading a different type, and a host that adds its
6
6
  * own knowledge (an issue tracker's linkage, say) rewrites rows in place.
7
7
  */
8
+ import type { ProviderStatus } from './providers/types.ts';
8
9
  /** What the sweep concluded about an item. */
9
10
  export type Classification = 'safe-to-delete' | 'safe-to-keep' | 'needs-investigation';
10
11
  /** The kinds of thing a sweep inventories. */
@@ -21,6 +22,8 @@ export interface SweepProgress {
21
22
  step: 'discover' | 'fetch' | 'prs' | 'worktrees' | 'local-branches' | 'remote-branches';
22
23
  done?: number;
23
24
  total?: number;
25
+ /** The CLI asked during the `prs` step: `gh`, `glab`. */
26
+ tool?: string;
24
27
  }
25
28
  /** Which categories to gather. */
26
29
  export declare const ALL_SCOPES: readonly ["local-branches", "worktrees", "remote-branches"];
@@ -47,8 +50,12 @@ export interface RepoInfo {
47
50
  mainBranch: string;
48
51
  remote: string | null;
49
52
  currentBranch: string;
50
- hasGh: boolean;
51
- ghAuthenticated: boolean;
53
+ /**
54
+ * The provider asked for pull requests and what its probe found: whether
55
+ * its CLI is installed and signed in. Null when none was asked
56
+ * (`--provider none`, `--no-gh`, or a sweep without `--remotes`).
57
+ */
58
+ provider: ProviderStatus | null;
52
59
  /** True when the sweep fetched the remote before classifying. */
53
60
  fetched: boolean;
54
61
  }
@@ -1,2 +1,2 @@
1
1
  /** This package's version, from its package.json when it was built. */
2
- export declare const VERSION = "0.13.0";
2
+ export declare const VERSION = "0.14.2";
@@ -1,3 +1,3 @@
1
1
  // Generated by cli-tool-stamp-version from package.json. Do not edit.
2
2
  /** This package's version, from its package.json when it was built. */
3
- export const VERSION = '0.13.0';
3
+ export const VERSION = '0.14.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osdlc/repo-cleaner",
3
- "version": "0.13.0",
3
+ "version": "0.14.2",
4
4
  "description": "What has been merged into the default branch, and what can go away. Inventories local branches, worktrees and remote branches across one repository or many, classifying each with the evidence behind the verdict and the command that would act on it. Plans and applies approved deletes, compare-and-swap.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "sksizer",
@@ -49,12 +49,12 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "zod": "^4.6.6",
52
- "@osdlc/cli-tool": "^0.14.0",
53
- "@osdlc/command-seam": "^0.14.0",
52
+ "@osdlc/cli-tool": "^0.15.0",
54
53
  "@osdlc/path-guard": "^0.3.1",
54
+ "@osdlc/command-seam": "^0.14.0",
55
55
  "@osdlc/typed-gh": "^0.14.1",
56
- "@osdlc/ux": "^0.1.1",
57
- "@osdlc/typed-git": "^0.12.0"
56
+ "@osdlc/typed-git": "^0.12.0",
57
+ "@osdlc/ux": "^0.1.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^26.1.0",