@uniqbit/mate-core 0.18.5 → 1.0.0-canary.0
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/claude-plugin/hooks/companion-instructions.mjs +9 -0
- package/claude-plugin/hooks/hooks.json +12 -2
- package/package.json +10 -2
- package/src/brand/art.generated.ts +69 -0
- package/src/brand/index.ts +12 -0
- package/src/brand/palette.ts +19 -0
- package/src/brand/svg.generated.ts +8 -0
- package/src/brand/terminal.ts +83 -0
- package/src/brand/types.ts +33 -0
- package/src/cli/commands/artifact/artifact.ts +8 -0
- package/src/cli/commands/artifact/delivery/archive.ts +117 -0
- package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
- package/src/cli/commands/artifact/delivery/environment.ts +104 -0
- package/src/cli/commands/artifact/delivery/link.ts +154 -0
- package/src/cli/commands/artifact/delivery/output.ts +58 -0
- package/src/cli/commands/artifact/finish/command.ts +33 -3
- package/src/cli/commands/artifact/finish/engine.ts +47 -3
- package/src/cli/commands/artifact/finish/finisher.ts +10 -0
- package/src/cli/commands/artifact/finish/openspec.ts +177 -138
- package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
- package/src/cli/commands/artifact/pending/command.ts +31 -3
- package/src/cli/commands/artifact/pending/discovery.ts +21 -57
- package/src/cli/commands/cap/graphify.ts +52 -3
- package/src/cli/commands/cap/index-cmd.ts +8 -1
- package/src/cli/commands/cap/openspec.ts +13 -25
- package/src/cli/commands/companion/open.ts +56 -39
- package/src/cli/commands/companion/update.ts +75 -42
- package/src/cli/commands/doctor.ts +176 -3
- package/src/cli/commands/plugin/install.ts +9 -4
- package/src/cli/commands/plugin/plugin.ts +3 -2
- package/src/cli/commands/report/adapter.ts +11 -2
- package/src/cli/commands/report/contract.ts +21 -0
- package/src/cli/commands/report/highlight.ts +34 -4
- package/src/cli/commands/report/markdown.ts +110 -0
- package/src/cli/commands/report/palette.ts +118 -0
- package/src/cli/commands/report/renderer.ts +456 -153
- package/src/cli/commands/report/types.ts +16 -0
- package/src/cli/commands/setup.ts +7 -15
- package/src/cli/commands/studio/client-asset.ts +16 -0
- package/src/cli/commands/studio/openspec-cli.ts +10 -58
- package/src/cli/commands/studio/routes.ts +2 -0
- package/src/cli/commands/studio/selection.ts +19 -1
- package/src/cli/commands/studio/server.ts +42 -1
- package/src/cli/commands/studio/views/client.ts +6 -616
- package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
- package/src/cli/commands/studio/views/document.tsx +137 -69
- package/src/cli/commands/studio/views/markdown.tsx +250 -0
- package/src/cli/commands/studio/views/palette.ts +134 -0
- package/src/cli/commands/studio/views/reports/index.tsx +2 -7
- package/src/cli/commands/studio/views/skills/index.tsx +43 -62
- package/src/cli/commands/studio/views/specs/index.tsx +7 -15
- package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
- package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
- package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
- package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
- package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
- package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
- package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
- package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
- package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
- package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
- package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
- package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
- package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
- package/src/cli/commands/studio/views/styles.ts +217 -206
- package/src/cli/commands/studio/views/vault/state.ts +3 -5
- package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
- package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
- package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
- package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
- package/src/cli/commands/wrap.ts +5 -13
- package/src/cli/main.ts +4 -1
- package/src/cli/parse-flags.ts +25 -7
- package/src/cli/setup-selector.tsx +38 -162
- package/src/cli/usage.ts +4 -0
- package/src/hooks/companion-instructions.ts +190 -0
- package/src/hooks/hook-io.ts +63 -0
- package/src/hooks/session-banner.ts +21 -29
- package/src/hooks/session-guidance.ts +9 -17
- package/src/hooks/session-prepare.ts +115 -28
- package/src/hooks/validate-artifact-path.ts +82 -24
- package/src/index.ts +4 -0
- package/src/lib/change-layout.ts +154 -0
- package/src/lib/components/wizard-header.tsx +3 -1
- package/src/lib/context-mode-package.ts +24 -11
- package/src/lib/delivery/archive-change.ts +154 -0
- package/src/lib/delivery/branches.ts +108 -0
- package/src/lib/delivery/environment.ts +29 -0
- package/src/lib/delivery/forge.ts +279 -0
- package/src/lib/delivery/overlap.ts +202 -0
- package/src/lib/delivery/record-delivery.ts +196 -0
- package/src/lib/delivery/record.ts +343 -0
- package/src/lib/delivery/refresh.ts +174 -0
- package/src/lib/delivery/repository-access.ts +97 -0
- package/src/lib/delivery/stages.ts +111 -0
- package/src/lib/delivery/token.ts +58 -0
- package/src/lib/frontmatter.ts +18 -0
- package/src/lib/opencode-plugin-package.ts +5 -5
- package/src/lib/openspec-cli.ts +116 -0
- package/src/lib/openspec-scopes.ts +86 -0
- package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
- package/src/lib/orchestrator/adapters/claude.ts +48 -40
- package/src/lib/orchestrator/adapters/opencode.ts +2 -0
- package/src/lib/orchestrator/editor.ts +9 -9
- package/src/lib/orchestrator/openspec-store.ts +14 -54
- package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
- package/src/lib/orchestrator/projection-claude-links.ts +159 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +19 -137
- package/src/lib/orchestrator/projection-entries.ts +45 -14
- package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
- package/src/lib/orchestrator/projection-types.ts +3 -0
- package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
- package/src/lib/orchestrator/session-prepare.ts +45 -5
- package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
- package/src/lib/orchestrator/types.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +22 -1
- package/src/lib/package-paths.ts +8 -0
- package/src/opencode/companion-hooks.ts +64 -5
- package/src/opencode/companion-policy.ts +37 -18
- package/src/opencode/projected-guidance.ts +4 -1
- package/src/opencode/session-prepare.ts +2 -1
- package/src/opencode/tui.tsx +4 -2
- package/src/runtime/artifact-refusal.ts +63 -0
- package/src/runtime/claude-delivery-plan.ts +89 -7
- package/src/runtime/commit-attribution-guard.ts +181 -0
- package/src/runtime/companion-guidance.ts +15 -3
- package/src/runtime/companion-instructions.ts +340 -0
- package/src/runtime/error-message.ts +4 -0
- package/src/runtime/index.ts +5 -0
- package/src/runtime/managed-block.ts +117 -0
- package/src/runtime/markdown-scan.ts +266 -0
- package/src/runtime/policy.ts +15 -1
- package/src/runtime/projected-flag.ts +7 -0
- package/src/runtime/projected-guidance.ts +1 -0
- package/src/runtime/runtime-document-manifest.ts +62 -11
- package/src/runtime/secret-guard.ts +148 -0
- package/src/runtime/secret-patterns.ts +164 -0
- package/src/runtime/secret-scan.ts +90 -0
- package/src/runtime/session-kind.ts +13 -0
- package/src/runtime/shell-heredoc.ts +81 -0
- package/src/runtime/wrap-state.ts +36 -2
- package/src/runtime/write-atomic.ts +64 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
- package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
- package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
- package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
- package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
- package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
- package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
- package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
- package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
- package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
- package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
- package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
- package/src/templates/mate-skills/{claude/mate-artifact-publish → agents/mate-publish-artifact}/SKILL.md +91 -23
- package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
- package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
- package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
- package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
- package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
- package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
- package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
- package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
- package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
- package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
- package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
- package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
- package/src/templates/mate-skills/{agents/mate-artifact-publish → claude/mate-publish-artifact}/SKILL.md +91 -23
- package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
- package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
- package/src/templates/mate-skills/openspec-references.ts +140 -0
- package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
- package/src/tools/setup/capabilities/context-mode.ts +25 -5
- package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
- package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
- package/src/tools/setup/capabilities/graphify.ts +12 -0
- package/src/tools/setup/capabilities/openspec.ts +151 -270
- package/src/tools/setup/capabilities/tokensave.ts +36 -3
- package/src/tools/setup/context-services.ts +9 -17
- package/src/tools/setup/dynamic-plugins/install.ts +24 -28
- package/src/tools/setup/engine.ts +97 -21
- package/src/tools/setup/managed-output.ts +51 -0
- package/src/tools/setup/mate.ts +86 -11
- package/src/tools/setup/plugin.ts +35 -0
- package/src/tools/setup/plugins/guidance.ts +7 -4
- package/src/tools/setup/providers/agent-file-sections.ts +4 -2
- package/src/tools/setup/providers/claude-attribution.ts +65 -0
- package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
- package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
- package/src/tools/setup/providers/claude.ts +284 -8
- package/src/tools/setup/providers/opencode.ts +180 -1
- package/src/tools/setup/utils.ts +14 -4
- package/src/tools/setup.ts +126 -10
- package/wrappers/bin/graphify +52 -4
- package/wrappers/bin/openspec +33 -9
- package/wrappers/lib/graphify-gate.mjs +8 -0
- package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
- package/src/tools/setup/runtime-documents.ts +0 -291
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parse, stringify } from "yaml";
|
|
5
|
+
import { errorMessage } from "../../runtime/error-message";
|
|
6
|
+
import { changeNameOf } from "../change-layout";
|
|
7
|
+
import { scopedRepositories } from "../openspec-scopes";
|
|
8
|
+
|
|
9
|
+
/** The Delivery Record's file name at a change root; it moves with the change into its archive. */
|
|
10
|
+
export const DELIVERY_FILE = "delivery.yaml";
|
|
11
|
+
|
|
12
|
+
/** A merge result: `open` is "not yet merged", `closed-unmerged` is "dropped". */
|
|
13
|
+
export type MergeState = "merged" | "open" | "closed-unmerged";
|
|
14
|
+
|
|
15
|
+
/** Where a merge result came from; a user answer is recorded as such, never as forge truth. */
|
|
16
|
+
export type MergeSource = "forge" | "user";
|
|
17
|
+
|
|
18
|
+
/** What the forge reports about a merge, when it reports anything. */
|
|
19
|
+
export interface MergeProvenance {
|
|
20
|
+
mergedAt?: string;
|
|
21
|
+
mergeCommit?: string;
|
|
22
|
+
/** Provenance only; a merger is never presented as an artifact reviewer. */
|
|
23
|
+
mergedBy?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MergeResult extends MergeProvenance {
|
|
27
|
+
state: MergeState;
|
|
28
|
+
source: MergeSource;
|
|
29
|
+
/** When the result was recorded (ISO 8601). */
|
|
30
|
+
checkedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The provenance fields `source` carries, without empty ones. */
|
|
34
|
+
export function pickProvenance(source: MergeProvenance): MergeProvenance {
|
|
35
|
+
return {
|
|
36
|
+
...(source.mergedAt ? { mergedAt: source.mergedAt } : {}),
|
|
37
|
+
...(source.mergeCommit ? { mergeCommit: source.mergeCommit } : {}),
|
|
38
|
+
...(source.mergedBy ? { mergedBy: source.mergedBy } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One Working Repository's delivery: a branch, or a reason no branch applies. */
|
|
43
|
+
export interface Delivery {
|
|
44
|
+
/** Canonical `org/repository` of the Working Repository. */
|
|
45
|
+
repository: string;
|
|
46
|
+
branch?: string;
|
|
47
|
+
/** True when the branch is protected and the developer opted into direct delivery. */
|
|
48
|
+
direct?: boolean;
|
|
49
|
+
/** Informational PR URL; the branch, not the PR, is what a change is delivered through. */
|
|
50
|
+
pr?: string;
|
|
51
|
+
merge?: MergeResult;
|
|
52
|
+
notApplicable?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DeliveryRecord {
|
|
56
|
+
version: 1;
|
|
57
|
+
change: string;
|
|
58
|
+
deliveries: Delivery[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type DeliveryPatch = Partial<Omit<Delivery, "repository">>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* - `legacy`: no record (archived before Delivery Records, or past `mate artifact archive`)
|
|
65
|
+
* - `unlinked`: a record without any delivery
|
|
66
|
+
* - `not-applicable`: every delivery carries a not-applicable reason
|
|
67
|
+
* - `merged`: every branch delivery is merged (the rest not applicable)
|
|
68
|
+
* - `dropped`: some branch was closed without merging
|
|
69
|
+
* - `waiting`: some branch has no merge result yet, or is still open, or a scoped
|
|
70
|
+
* repository has no delivery recorded
|
|
71
|
+
*/
|
|
72
|
+
export type DeliveryStatus =
|
|
73
|
+
| "legacy"
|
|
74
|
+
| "unlinked"
|
|
75
|
+
| "not-applicable"
|
|
76
|
+
| "merged"
|
|
77
|
+
| "dropped"
|
|
78
|
+
| "waiting";
|
|
79
|
+
|
|
80
|
+
export interface DeliverySettlement {
|
|
81
|
+
status: DeliveryStatus;
|
|
82
|
+
/** Repositories whose delivery keeps the change from being publishable. */
|
|
83
|
+
unsettled: string[];
|
|
84
|
+
/** Scoped repositories without any delivery, branch or not-applicable; a subset of `unsettled`. */
|
|
85
|
+
missing: string[];
|
|
86
|
+
/** Recorded repositories matching none of the change's scopes; empty when it has none. */
|
|
87
|
+
unscoped: UnscopedDelivery[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface UnscopedDelivery {
|
|
91
|
+
repository: string;
|
|
92
|
+
/** The scoped spelling it most likely means, when one is obvious. */
|
|
93
|
+
closest?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether an archive with this status may be published; anything else is held back. A
|
|
98
|
+
* `legacy` archive (made before Delivery Records) publishes, with a warning that its merge
|
|
99
|
+
* was not checked.
|
|
100
|
+
*/
|
|
101
|
+
export function isPublishable(status: DeliveryStatus): boolean {
|
|
102
|
+
return status === "merged" || status === "not-applicable" || status === "legacy";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The warning a `legacy` archive publishes with. */
|
|
106
|
+
export function legacyDeliveryWarning(anchorName: string): string {
|
|
107
|
+
return `${anchorName} has no delivery.yaml, so its merge was not checked; publishing it as a legacy archive.`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function defaultDeliveryBranch(change: string): string {
|
|
111
|
+
return `change/${change}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function unlinkedRecord(change: string): DeliveryRecord {
|
|
115
|
+
return { version: 1, change, deliveries: [] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** GitHub slugs are case-insensitive, so `Acme/Api` and `acme/api` name one repository. */
|
|
119
|
+
export function sameRepository(a: string, b: string): boolean {
|
|
120
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function findDelivery(record: DeliveryRecord, repository: string): Delivery | undefined {
|
|
124
|
+
return record.deliveries.find((entry) => sameRepository(entry.repository, repository));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Applies `patch` to the repository's delivery, creating it when absent; an existing
|
|
129
|
+
* delivery keeps its spelling of the repository. A branch and a not-applicable reason
|
|
130
|
+
* exclude each other, and a new branch or a switch between direct and PR delivery drops
|
|
131
|
+
* the previous merge result and PR, which described a different delivery; a different PR
|
|
132
|
+
* drops the merge result.
|
|
133
|
+
*/
|
|
134
|
+
export function upsertDelivery(
|
|
135
|
+
record: DeliveryRecord,
|
|
136
|
+
requested: string,
|
|
137
|
+
patch: DeliveryPatch,
|
|
138
|
+
): DeliveryRecord {
|
|
139
|
+
const existing = findDelivery(record, requested);
|
|
140
|
+
const repository = existing?.repository ?? requested;
|
|
141
|
+
let next: Delivery = { ...existing, repository };
|
|
142
|
+
if (patch.notApplicable !== undefined) {
|
|
143
|
+
next = { repository, notApplicable: patch.notApplicable };
|
|
144
|
+
} else {
|
|
145
|
+
const newBranch = patch.branch !== undefined && patch.branch !== next.branch;
|
|
146
|
+
const newMode = patch.direct !== undefined && patch.direct !== Boolean(next.direct);
|
|
147
|
+
if (newBranch || newMode) {
|
|
148
|
+
next = { repository, branch: patch.branch ?? next.branch };
|
|
149
|
+
}
|
|
150
|
+
if (patch.direct !== undefined) next.direct = patch.direct;
|
|
151
|
+
if (patch.pr !== undefined && patch.pr !== next.pr) {
|
|
152
|
+
delete next.merge;
|
|
153
|
+
next.pr = patch.pr;
|
|
154
|
+
}
|
|
155
|
+
if (patch.merge !== undefined) next.merge = patch.merge;
|
|
156
|
+
if (next.direct === false) delete next.direct;
|
|
157
|
+
}
|
|
158
|
+
const deliveries = existing
|
|
159
|
+
? record.deliveries.map((entry) => (entry === existing ? next : entry))
|
|
160
|
+
: [...record.deliveries, next];
|
|
161
|
+
return { ...record, deliveries };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Settles a record against the repositories the change is scoped to: each needs its own
|
|
166
|
+
* delivery, so linking only one repository of a cross-repository change settles nothing.
|
|
167
|
+
*/
|
|
168
|
+
export function settleDelivery(
|
|
169
|
+
record: DeliveryRecord | null,
|
|
170
|
+
scoped: readonly string[] = [],
|
|
171
|
+
): DeliverySettlement {
|
|
172
|
+
if (!record) return { status: "legacy", unsettled: [], missing: [], unscoped: [] };
|
|
173
|
+
const missing = scoped.filter((repository) => !findDelivery(record, repository));
|
|
174
|
+
const unscoped = unscopedDeliveries(record, scoped);
|
|
175
|
+
if (record.deliveries.length === 0) {
|
|
176
|
+
return { status: "unlinked", unsettled: missing, missing, unscoped };
|
|
177
|
+
}
|
|
178
|
+
const branches = record.deliveries.filter((entry) => entry.notApplicable === undefined);
|
|
179
|
+
const unsettled = [
|
|
180
|
+
...branches.filter((entry) => entry.merge?.state !== "merged").map((entry) => entry.repository),
|
|
181
|
+
...missing,
|
|
182
|
+
];
|
|
183
|
+
if (unsettled.length === 0) {
|
|
184
|
+
const status = branches.length === 0 ? "not-applicable" : "merged";
|
|
185
|
+
return { status, unsettled, missing, unscoped };
|
|
186
|
+
}
|
|
187
|
+
const dropped = branches.some((entry) => entry.merge?.state === "closed-unmerged");
|
|
188
|
+
return { status: dropped ? "dropped" : "waiting", unsettled, missing, unscoped };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function unscopedDeliveries(record: DeliveryRecord, scoped: readonly string[]): UnscopedDelivery[] {
|
|
192
|
+
if (scoped.length === 0) return [];
|
|
193
|
+
return record.deliveries
|
|
194
|
+
.filter((entry) => !scoped.some((repository) => sameRepository(repository, entry.repository)))
|
|
195
|
+
.map((entry) => {
|
|
196
|
+
const closest = scoped.find((repository) => likelySame(repository, entry.repository));
|
|
197
|
+
return closest ? { repository: entry.repository, closest } : { repository: entry.repository };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Same last segment; this also covers one path being a suffix of the other (a dropped namespace). */
|
|
202
|
+
function likelySame(a: string, b: string): boolean {
|
|
203
|
+
return a.toLowerCase().split("/").at(-1) === b.toLowerCase().split("/").at(-1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function malformed(file: string, reason: string): Error {
|
|
207
|
+
return new Error(`${file}: malformed ${DELIVERY_FILE}: ${reason}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function optionalString(value: unknown, file: string, field: string): string | undefined {
|
|
211
|
+
if (value === undefined || value === null) return undefined;
|
|
212
|
+
if (typeof value !== "string") throw malformed(file, `${field} must be a string`);
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const MERGE_STATES: readonly MergeState[] = ["merged", "open", "closed-unmerged"];
|
|
217
|
+
const MERGE_SOURCES: readonly MergeSource[] = ["forge", "user"];
|
|
218
|
+
|
|
219
|
+
function parseMerge(value: unknown, file: string): MergeResult | undefined {
|
|
220
|
+
if (value === undefined || value === null) return undefined;
|
|
221
|
+
if (typeof value !== "object") throw malformed(file, "merge must be a mapping");
|
|
222
|
+
const merge = value as Record<string, unknown>;
|
|
223
|
+
if (!MERGE_STATES.includes(merge.state as MergeState)) {
|
|
224
|
+
throw malformed(file, `merge.state must be one of ${MERGE_STATES.join(", ")}`);
|
|
225
|
+
}
|
|
226
|
+
if (!MERGE_SOURCES.includes(merge.source as MergeSource)) {
|
|
227
|
+
throw malformed(file, `merge.source must be one of ${MERGE_SOURCES.join(", ")}`);
|
|
228
|
+
}
|
|
229
|
+
const result: MergeResult = {
|
|
230
|
+
state: merge.state as MergeState,
|
|
231
|
+
source: merge.source as MergeSource,
|
|
232
|
+
checkedAt: optionalString(merge.checkedAt, file, "merge.checkedAt") ?? "",
|
|
233
|
+
};
|
|
234
|
+
for (const key of ["mergedAt", "mergeCommit", "mergedBy"] as const) {
|
|
235
|
+
const field = optionalString(merge[key], file, `merge.${key}`);
|
|
236
|
+
if (field !== undefined) result[key] = field;
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parseDelivery(value: unknown, file: string): Delivery {
|
|
242
|
+
if (!value || typeof value !== "object") throw malformed(file, "a delivery must be a mapping");
|
|
243
|
+
const entry = value as Record<string, unknown>;
|
|
244
|
+
const repository = optionalString(entry.repository, file, "repository");
|
|
245
|
+
if (!repository) throw malformed(file, "every delivery names its repository");
|
|
246
|
+
const delivery: Delivery = { repository };
|
|
247
|
+
const branch = optionalString(entry.branch, file, "branch");
|
|
248
|
+
const pr = optionalString(entry.pr, file, "pr");
|
|
249
|
+
const notApplicable = optionalString(entry.notApplicable, file, "notApplicable");
|
|
250
|
+
if (branch !== undefined) delivery.branch = branch;
|
|
251
|
+
if (entry.direct === true) delivery.direct = true;
|
|
252
|
+
if (pr !== undefined) delivery.pr = pr;
|
|
253
|
+
const merge = parseMerge(entry.merge, file);
|
|
254
|
+
if (merge) delivery.merge = merge;
|
|
255
|
+
if (notApplicable !== undefined) delivery.notApplicable = notApplicable;
|
|
256
|
+
return delivery;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Reads `delivery.yaml` from a change directory, active or archived. Null means no file:
|
|
261
|
+
* a legacy change. A file that exists but cannot be understood throws, so a damaged
|
|
262
|
+
* record is never mistaken for a legacy archive and published past the merge gate.
|
|
263
|
+
*/
|
|
264
|
+
export async function readDeliveryRecord(changeDir: string): Promise<DeliveryRecord | null> {
|
|
265
|
+
const file = path.join(changeDir, DELIVERY_FILE);
|
|
266
|
+
let source: string;
|
|
267
|
+
try {
|
|
268
|
+
source = await fs.readFile(file, "utf8");
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
let parsed: unknown;
|
|
274
|
+
try {
|
|
275
|
+
parsed = parse(source);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
throw malformed(file, errorMessage(error));
|
|
278
|
+
}
|
|
279
|
+
if (!parsed || typeof parsed !== "object") throw malformed(file, "not a mapping");
|
|
280
|
+
const document = parsed as Record<string, unknown>;
|
|
281
|
+
const deliveries = document.deliveries ?? [];
|
|
282
|
+
if (!Array.isArray(deliveries)) throw malformed(file, "deliveries must be a list");
|
|
283
|
+
return {
|
|
284
|
+
version: 1,
|
|
285
|
+
change:
|
|
286
|
+
optionalString(document.change, file, "change") ?? changeNameOf(path.basename(changeDir)),
|
|
287
|
+
deliveries: deliveries.map((entry) => parseDelivery(entry, file)),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The change's record, or an unlinked one when none was written yet. The name comes from
|
|
293
|
+
* `change`, not the file, so a renamed change's record follows its directory.
|
|
294
|
+
*/
|
|
295
|
+
export async function readOrUnlinkedRecord(
|
|
296
|
+
changeDir: string,
|
|
297
|
+
change: string,
|
|
298
|
+
): Promise<DeliveryRecord> {
|
|
299
|
+
const name = changeNameOf(change);
|
|
300
|
+
const record = await readDeliveryRecord(changeDir);
|
|
301
|
+
return record ? { ...record, change: name } : unlinkedRecord(name);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A change's record with its settlement; `error` names a record that exists but cannot be read. */
|
|
305
|
+
export interface DeliveryState extends DeliverySettlement {
|
|
306
|
+
record: DeliveryRecord | null;
|
|
307
|
+
error?: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The change's settlement against its own scopes; see {@link settleDelivery}. */
|
|
311
|
+
export async function settleChangeDir(
|
|
312
|
+
changeDir: string,
|
|
313
|
+
record: DeliveryRecord | null,
|
|
314
|
+
): Promise<DeliverySettlement> {
|
|
315
|
+
return settleDelivery(record, await scopedRepositories(changeDir));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Like {@link readDeliveryRecord}, settled against the change's scopes; a damaged record is
|
|
320
|
+
* reported as `unlinked` with its error.
|
|
321
|
+
*/
|
|
322
|
+
export async function readDeliveryState(changeDir: string): Promise<DeliveryState> {
|
|
323
|
+
try {
|
|
324
|
+
const record = await readDeliveryRecord(changeDir);
|
|
325
|
+
return { ...(await settleChangeDir(changeDir, record)), record };
|
|
326
|
+
} catch (error) {
|
|
327
|
+
return {
|
|
328
|
+
status: "unlinked",
|
|
329
|
+
unsettled: [],
|
|
330
|
+
missing: [],
|
|
331
|
+
unscoped: [],
|
|
332
|
+
record: null,
|
|
333
|
+
error: errorMessage(error),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function writeDeliveryRecord(
|
|
339
|
+
changeDir: string,
|
|
340
|
+
record: DeliveryRecord,
|
|
341
|
+
): Promise<void> {
|
|
342
|
+
await fs.writeFile(path.join(changeDir, DELIVERY_FILE), stringify(record), "utf8");
|
|
343
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { ForgeAdapter, ForgeFailure, ForgeRepo, PullCandidate } from "./forge";
|
|
2
|
+
import {
|
|
3
|
+
pickProvenance,
|
|
4
|
+
sameRepository,
|
|
5
|
+
upsertDelivery,
|
|
6
|
+
type DeliveryRecord,
|
|
7
|
+
type MergeProvenance,
|
|
8
|
+
type MergeState,
|
|
9
|
+
} from "./record";
|
|
10
|
+
|
|
11
|
+
/** Why a branch's merge state must come from the user instead of the forge. */
|
|
12
|
+
export type AnswerReason =
|
|
13
|
+
| "no-token"
|
|
14
|
+
| "unsupported-host"
|
|
15
|
+
| "forge-failure"
|
|
16
|
+
| "no-pr"
|
|
17
|
+
| "ambiguous"
|
|
18
|
+
| "direct";
|
|
19
|
+
|
|
20
|
+
export interface BranchOutcome {
|
|
21
|
+
repository: string;
|
|
22
|
+
branch: string;
|
|
23
|
+
/** What is now known; `unknown` when the forge could not answer. */
|
|
24
|
+
result: MergeState | "ambiguous" | "none" | "unknown";
|
|
25
|
+
pr?: string;
|
|
26
|
+
candidates?: PullCandidate[];
|
|
27
|
+
failure?: ForgeFailure;
|
|
28
|
+
/** True when the user has to answer merged, not yet merged, or dropped. */
|
|
29
|
+
needsAnswer: boolean;
|
|
30
|
+
answerReason?: AnswerReason;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ForgeAccess =
|
|
34
|
+
| { adapter: ForgeAdapter; repo: ForgeRepo }
|
|
35
|
+
| { unavailable: "no-token" | "unsupported-host" };
|
|
36
|
+
|
|
37
|
+
/** The clock, forge, and local Git a refresh reads. */
|
|
38
|
+
export interface RefreshAccess {
|
|
39
|
+
now: () => Date;
|
|
40
|
+
forgeAccess: (repository: string) => Promise<ForgeAccess>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RefreshOptions extends RefreshAccess {
|
|
44
|
+
/** Restricts the refresh to one repository's delivery. */
|
|
45
|
+
repository?: string;
|
|
46
|
+
/** Shared across changes in one run, so a branch is asked about once. */
|
|
47
|
+
cache?: RefreshCache;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The PR number in a `…/pull/<n>` URL. */
|
|
51
|
+
function prNumberFrom(url: string | undefined): number | undefined {
|
|
52
|
+
const match = url ? /\/pull\/(\d+)/.exec(url) : null;
|
|
53
|
+
return match ? Number(match[1]) : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Asks the forge about every branch delivery of a record (or the one repository named)
|
|
58
|
+
* and records each definite answer with source `forge`. A direct delivery is never settled
|
|
59
|
+
* here: it always needs the user's answer. Nothing is recorded for an ambiguous,
|
|
60
|
+
* missing, or failed lookup: those come back as outcomes needing an answer. A delivery the
|
|
61
|
+
* user answered as merged or dropped is skipped.
|
|
62
|
+
*/
|
|
63
|
+
export async function refreshRecord(
|
|
64
|
+
record: DeliveryRecord,
|
|
65
|
+
options: RefreshOptions,
|
|
66
|
+
): Promise<{ record: DeliveryRecord; outcomes: BranchOutcome[] }> {
|
|
67
|
+
let next = record;
|
|
68
|
+
const outcomes: BranchOutcome[] = [];
|
|
69
|
+
const cache: RefreshCache = options.cache ?? new Map();
|
|
70
|
+
|
|
71
|
+
for (const delivery of record.deliveries) {
|
|
72
|
+
if (delivery.notApplicable !== undefined || !delivery.branch) continue;
|
|
73
|
+
if (options.repository && !sameRepository(delivery.repository, options.repository)) continue;
|
|
74
|
+
// A user answer of merged or dropped stands; the forge may still settle a "not yet merged".
|
|
75
|
+
if (delivery.merge?.source === "user" && delivery.merge.state !== "open") continue;
|
|
76
|
+
const { repository, branch } = delivery;
|
|
77
|
+
// A recorded PR URL picks that PR when several have the branch as head, unless it was dropped.
|
|
78
|
+
const prNumber = prNumberFrom(delivery.pr);
|
|
79
|
+
const direct = Boolean(delivery.direct);
|
|
80
|
+
// A direct delivery is checked in Git, a PR delivery on the forge: never share an answer.
|
|
81
|
+
const key = JSON.stringify([repository, branch, prNumber ?? null, direct]);
|
|
82
|
+
|
|
83
|
+
let outcome = cache.get(key);
|
|
84
|
+
if (!outcome) {
|
|
85
|
+
outcome = await lookup(repository, branch, direct, prNumber, options);
|
|
86
|
+
cache.set(key, outcome);
|
|
87
|
+
}
|
|
88
|
+
outcomes.push(outcome);
|
|
89
|
+
|
|
90
|
+
if (
|
|
91
|
+
outcome.result === "merged" ||
|
|
92
|
+
outcome.result === "open" ||
|
|
93
|
+
outcome.result === "closed-unmerged"
|
|
94
|
+
) {
|
|
95
|
+
const merge = {
|
|
96
|
+
state: outcome.result,
|
|
97
|
+
source: "forge" as const,
|
|
98
|
+
checkedAt: options.now().toISOString(),
|
|
99
|
+
...outcome.provenance,
|
|
100
|
+
};
|
|
101
|
+
next = upsertDelivery(next, repository, { merge, ...(outcome.pr ? { pr: outcome.pr } : {}) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { record: next, outcomes: outcomes.map(publicOutcome) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Lookups by repository, branch, and PR number. */
|
|
108
|
+
export type RefreshCache = Map<string, LookupOutcome>;
|
|
109
|
+
|
|
110
|
+
interface LookupOutcome extends BranchOutcome {
|
|
111
|
+
provenance?: MergeProvenance;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function publicOutcome(outcome: LookupOutcome): BranchOutcome {
|
|
115
|
+
const { provenance: _provenance, ...rest } = outcome;
|
|
116
|
+
return rest;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function lookup(
|
|
120
|
+
repository: string,
|
|
121
|
+
branch: string,
|
|
122
|
+
direct: boolean,
|
|
123
|
+
prNumber: number | undefined,
|
|
124
|
+
options: RefreshOptions,
|
|
125
|
+
): Promise<LookupOutcome> {
|
|
126
|
+
const base = { repository, branch };
|
|
127
|
+
if (direct) {
|
|
128
|
+
// Git cannot tell this change's commits from the user's other pushes, so the user decides.
|
|
129
|
+
return { ...base, result: "unknown", needsAnswer: true, answerReason: "direct" };
|
|
130
|
+
}
|
|
131
|
+
const access = await options.forgeAccess(repository);
|
|
132
|
+
if ("unavailable" in access) {
|
|
133
|
+
return { ...base, result: "unknown", needsAnswer: true, answerReason: access.unavailable };
|
|
134
|
+
}
|
|
135
|
+
let answer = await access.adapter.pullsForBranch(access.repo, branch, prNumber);
|
|
136
|
+
// A recorded PR that is closed or gone must not hide a newer PR from the same branch.
|
|
137
|
+
if (
|
|
138
|
+
prNumber !== undefined &&
|
|
139
|
+
answer.ok &&
|
|
140
|
+
(answer.value.kind === "none" || answer.value.kind === "closed-unmerged")
|
|
141
|
+
) {
|
|
142
|
+
const wider = await access.adapter.pullsForBranch(access.repo, branch);
|
|
143
|
+
if (wider.ok && wider.value.kind !== "none") answer = wider;
|
|
144
|
+
}
|
|
145
|
+
if (!answer.ok) {
|
|
146
|
+
return {
|
|
147
|
+
...base,
|
|
148
|
+
result: "unknown",
|
|
149
|
+
failure: answer.failure,
|
|
150
|
+
needsAnswer: true,
|
|
151
|
+
answerReason: "forge-failure",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const value = answer.value;
|
|
155
|
+
if (value.kind === "none") {
|
|
156
|
+
return { ...base, result: "none", needsAnswer: true, answerReason: "no-pr" };
|
|
157
|
+
}
|
|
158
|
+
if (value.kind === "ambiguous") {
|
|
159
|
+
return {
|
|
160
|
+
...base,
|
|
161
|
+
result: "ambiguous",
|
|
162
|
+
candidates: value.candidates,
|
|
163
|
+
needsAnswer: true,
|
|
164
|
+
answerReason: "ambiguous",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
...base,
|
|
169
|
+
result: value.kind,
|
|
170
|
+
pr: value.pr.url,
|
|
171
|
+
needsAnswer: false,
|
|
172
|
+
provenance: pickProvenance(value.pr),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { classifyBranch, type BranchProtection } from "./branches";
|
|
2
|
+
import { sameRepository } from "./record";
|
|
3
|
+
import type { DeliveryEnvironment } from "./environment";
|
|
4
|
+
import { forgeFor, forgeRepoOf, type ForgeDeps, type ForgeRepo } from "./forge";
|
|
5
|
+
import type { ForgeAccess } from "./refresh";
|
|
6
|
+
import { resolveForgeToken, type ForgeToken } from "./token";
|
|
7
|
+
|
|
8
|
+
/** What delivery needs to know about one repository, from local Git or the forge. */
|
|
9
|
+
export interface RepositoryAccess {
|
|
10
|
+
/** The Working Repository's from local Git; another's from the forge, else `main`. */
|
|
11
|
+
defaultBranch(): Promise<string>;
|
|
12
|
+
/** Protected when it is the default branch or matches a configured pattern. */
|
|
13
|
+
protection(branch: string): Promise<BranchProtection>;
|
|
14
|
+
forge(): Promise<ForgeAccess>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RepositoryAccessDeps {
|
|
18
|
+
resolveToken?: () => Promise<ForgeToken | null>;
|
|
19
|
+
forge?: ForgeDeps;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Access per repository, the Working Repository's through local Git first. The forge token
|
|
24
|
+
* is resolved at most once, and only when a lookup needs it.
|
|
25
|
+
*/
|
|
26
|
+
export function repositoryAccessFor(
|
|
27
|
+
environment: DeliveryEnvironment,
|
|
28
|
+
deps: RepositoryAccessDeps = {},
|
|
29
|
+
): (repository: string) => RepositoryAccess {
|
|
30
|
+
let token: Promise<ForgeToken | null> | undefined;
|
|
31
|
+
const resolveToken = (): Promise<ForgeToken | null> =>
|
|
32
|
+
(token ??= (deps.resolveToken ?? (() => resolveForgeToken()))());
|
|
33
|
+
const accesses = new Map<string, RepositoryAccess>();
|
|
34
|
+
|
|
35
|
+
return (repository) => {
|
|
36
|
+
const key = repository.toLowerCase();
|
|
37
|
+
let access = accesses.get(key);
|
|
38
|
+
if (!access) {
|
|
39
|
+
const slug = environment.workingRepo?.slug;
|
|
40
|
+
const working = slug && sameRepository(slug, repository) ? environment.workingRepo : null;
|
|
41
|
+
const forgeRepo = async (): Promise<ForgeRepo | null> =>
|
|
42
|
+
working?.forgeRepo ?? otherForgeRepo(environment, repository);
|
|
43
|
+
let forgeAccess: Promise<ForgeAccess> | undefined;
|
|
44
|
+
const forge = (): Promise<ForgeAccess> =>
|
|
45
|
+
(forgeAccess ??= connect(forgeRepo, resolveToken, deps.forge));
|
|
46
|
+
const defaultBranch = working ? () => working.git.defaultBranch() : forgeDefaultBranch(forge);
|
|
47
|
+
access = {
|
|
48
|
+
defaultBranch,
|
|
49
|
+
protection: async (branch) =>
|
|
50
|
+
classifyBranch(branch, {
|
|
51
|
+
defaultBranch: await defaultBranch(),
|
|
52
|
+
patterns: environment.protectedPatterns,
|
|
53
|
+
}),
|
|
54
|
+
forge,
|
|
55
|
+
};
|
|
56
|
+
accesses.set(key, access);
|
|
57
|
+
}
|
|
58
|
+
return access;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Another repository's host comes from a linked checkout's remote, else the Working
|
|
64
|
+
* Repository's; never assumed. Null when no host is known.
|
|
65
|
+
*/
|
|
66
|
+
async function otherForgeRepo(
|
|
67
|
+
environment: DeliveryEnvironment,
|
|
68
|
+
repository: string,
|
|
69
|
+
): Promise<ForgeRepo | null> {
|
|
70
|
+
const host = (await environment.hostOf?.(repository)) ?? environment.workingRepo?.forgeRepo?.host;
|
|
71
|
+
return host ? forgeRepoOf(host, repository) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function connect(
|
|
75
|
+
forgeRepo: () => Promise<ForgeRepo | null>,
|
|
76
|
+
resolveToken: () => Promise<ForgeToken | null>,
|
|
77
|
+
deps: ForgeDeps | undefined,
|
|
78
|
+
): Promise<ForgeAccess> {
|
|
79
|
+
const repo = await forgeRepo();
|
|
80
|
+
if (!repo) return { unavailable: "unsupported-host" };
|
|
81
|
+
const resolved = await resolveToken();
|
|
82
|
+
if (!resolved) return { unavailable: "no-token" };
|
|
83
|
+
const adapter = forgeFor(repo, resolved.token, deps);
|
|
84
|
+
return adapter ? { adapter, repo } : { unavailable: "unsupported-host" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Another repository's default branch from the forge, asked once; `main` when it cannot answer. */
|
|
88
|
+
function forgeDefaultBranch(forge: () => Promise<ForgeAccess>): () => Promise<string> {
|
|
89
|
+
let known: Promise<string> | undefined;
|
|
90
|
+
return () =>
|
|
91
|
+
(known ??= (async () => {
|
|
92
|
+
const access = await forge();
|
|
93
|
+
if (!("adapter" in access)) return "main";
|
|
94
|
+
const answer = await access.adapter.defaultBranch(access.repo);
|
|
95
|
+
return answer.ok ? answer.value : "main";
|
|
96
|
+
})());
|
|
97
|
+
}
|