@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,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge adapters answer read-only questions about a repository and its Delivery Branch:
|
|
3
|
+
* the default branch, whether the branch is protected, and which PR has it as head. GitHub (github.com) is the only adapter; every other host
|
|
4
|
+
* falls back to asking the user. Every failure is returned, never thrown, and the token
|
|
5
|
+
* is scrubbed from anything a failure carries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { errorMessage } from "../../runtime/error-message";
|
|
9
|
+
import { pickProvenance, type MergeProvenance, type MergeState } from "./record";
|
|
10
|
+
|
|
11
|
+
export interface ForgeRepo {
|
|
12
|
+
host: string;
|
|
13
|
+
/** Every path segment before the repository; nested groups keep theirs (`acme/platform`). */
|
|
14
|
+
owner: string;
|
|
15
|
+
repo: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PullCandidate {
|
|
19
|
+
number: number;
|
|
20
|
+
url: string;
|
|
21
|
+
state: MergeState;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PullDetail extends MergeProvenance {
|
|
25
|
+
number: number;
|
|
26
|
+
url: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type PullClassification =
|
|
30
|
+
| { kind: "merged"; pr: PullDetail }
|
|
31
|
+
| { kind: "open"; pr: PullDetail }
|
|
32
|
+
| { kind: "closed-unmerged"; pr: PullDetail }
|
|
33
|
+
| { kind: "ambiguous"; candidates: PullCandidate[] }
|
|
34
|
+
| { kind: "none" };
|
|
35
|
+
|
|
36
|
+
export type ForgeFailureReason = "permission" | "rate-limit" | "auth" | "network" | "unexpected";
|
|
37
|
+
|
|
38
|
+
export interface ForgeFailure {
|
|
39
|
+
reason: ForgeFailureReason;
|
|
40
|
+
message: string;
|
|
41
|
+
/** The fine-grained read permission a `permission` failure lacks. */
|
|
42
|
+
permission?: string;
|
|
43
|
+
/** The HTTP status the forge answered, when it answered. */
|
|
44
|
+
status?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ForgeOutcome<T> = { ok: true; value: T } | { ok: false; failure: ForgeFailure };
|
|
48
|
+
|
|
49
|
+
export interface ForgeAdapter {
|
|
50
|
+
id: string;
|
|
51
|
+
defaultBranch(repo: ForgeRepo): Promise<ForgeOutcome<string>>;
|
|
52
|
+
/** Classifies the PRs whose head is `branch`; `prNumber` picks one of several. */
|
|
53
|
+
pullsForBranch(
|
|
54
|
+
repo: ForgeRepo,
|
|
55
|
+
branch: string,
|
|
56
|
+
prNumber?: number,
|
|
57
|
+
): Promise<ForgeOutcome<PullClassification>>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type FetchLike = (
|
|
61
|
+
url: string,
|
|
62
|
+
init?: { headers?: Record<string, string> },
|
|
63
|
+
) => Promise<Response>;
|
|
64
|
+
|
|
65
|
+
export interface ForgeDeps {
|
|
66
|
+
fetch?: FetchLike;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parses SSH, scp-style, and HTTPS remotes; credentials in a URL are discarded. */
|
|
70
|
+
export function parseRemote(url: string): ForgeRepo | null {
|
|
71
|
+
const cleaned = url.trim();
|
|
72
|
+
let host: string;
|
|
73
|
+
let remotePath: string;
|
|
74
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(cleaned);
|
|
77
|
+
host = parsed.hostname;
|
|
78
|
+
remotePath = parsed.pathname;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// `user@` is optional: an SSH config alias such as `work:org/repo` has none.
|
|
84
|
+
const scp = /^(?:[^/\s@]+@)?([^/\s:]{2,}):(.+)$/.exec(cleaned);
|
|
85
|
+
if (!scp) return null;
|
|
86
|
+
host = scp[1];
|
|
87
|
+
remotePath = scp[2];
|
|
88
|
+
}
|
|
89
|
+
if (!host) return null;
|
|
90
|
+
return forgeRepoOf(host, remotePath.replace(/\.git\/?$/, ""));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Splits a repository path at its last segment; null below two segments. */
|
|
94
|
+
export function forgeRepoOf(host: string, path: string): ForgeRepo | null {
|
|
95
|
+
const parts = path.split("/").filter(Boolean);
|
|
96
|
+
if (parts.length < 2) return null;
|
|
97
|
+
return {
|
|
98
|
+
host: host.toLowerCase(),
|
|
99
|
+
owner: parts.slice(0, -1).join("/"),
|
|
100
|
+
repo: parts.at(-1) as string,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The repository's identity: its full path on the host, `owner/repo`. */
|
|
105
|
+
export function repositoryPath(repo: ForgeRepo): string {
|
|
106
|
+
return `${repo.owner}/${repo.repo}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The adapter for a remote's host, or null when the host has none. */
|
|
110
|
+
export function forgeFor(
|
|
111
|
+
repo: ForgeRepo,
|
|
112
|
+
token: string,
|
|
113
|
+
deps: ForgeDeps = {},
|
|
114
|
+
): ForgeAdapter | null {
|
|
115
|
+
return repo.host === "github.com" ? githubForge(token, deps) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Replaces every occurrence of the token, so no failure text can carry it. */
|
|
119
|
+
function redactToken(text: string, token: string): string {
|
|
120
|
+
return token ? text.split(token).join("[redacted]") : text;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const GITHUB_API = "https://api.github.com";
|
|
124
|
+
|
|
125
|
+
interface GithubPull {
|
|
126
|
+
number: number;
|
|
127
|
+
html_url: string;
|
|
128
|
+
state: string;
|
|
129
|
+
merged_at: string | null;
|
|
130
|
+
merge_commit_sha?: string | null;
|
|
131
|
+
merged_by?: { login?: string } | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function candidateState(pull: GithubPull): PullCandidate["state"] {
|
|
135
|
+
if (pull.merged_at) return "merged";
|
|
136
|
+
return pull.state === "open" ? "open" : "closed-unmerged";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function detail(pull: GithubPull): PullDetail {
|
|
140
|
+
return {
|
|
141
|
+
number: pull.number,
|
|
142
|
+
url: pull.html_url,
|
|
143
|
+
...pickProvenance({
|
|
144
|
+
mergedAt: pull.merged_at ?? undefined,
|
|
145
|
+
mergeCommit: (pull.merged_at && pull.merge_commit_sha) || undefined,
|
|
146
|
+
mergedBy: pull.merged_by?.login,
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function repoPath(repo: ForgeRepo): string {
|
|
152
|
+
return `/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function githubForge(token: string, deps: ForgeDeps = {}): ForgeAdapter {
|
|
156
|
+
const fetchFn: FetchLike = deps.fetch ?? ((url, init) => fetch(url, init));
|
|
157
|
+
|
|
158
|
+
async function get<T>(apiPath: string, permission: string): Promise<ForgeOutcome<T>> {
|
|
159
|
+
let response: Response;
|
|
160
|
+
try {
|
|
161
|
+
response = await fetchFn(`${GITHUB_API}${apiPath}`, {
|
|
162
|
+
headers: {
|
|
163
|
+
accept: "application/vnd.github+json",
|
|
164
|
+
authorization: `Bearer ${token}`,
|
|
165
|
+
"x-github-api-version": "2022-11-28",
|
|
166
|
+
"user-agent": "mate-cli",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
failure: {
|
|
173
|
+
reason: "network",
|
|
174
|
+
message: redactToken(`GitHub request failed: ${errorMessage(error)}`, token),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const fail = (
|
|
179
|
+
reason: ForgeFailureReason,
|
|
180
|
+
message: string,
|
|
181
|
+
extra: Pick<ForgeFailure, "permission"> = {},
|
|
182
|
+
): ForgeOutcome<T> => ({
|
|
183
|
+
ok: false,
|
|
184
|
+
failure: {
|
|
185
|
+
reason,
|
|
186
|
+
message: redactToken(message, token),
|
|
187
|
+
...extra,
|
|
188
|
+
...(response.ok ? {} : { status: response.status }),
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
if (response.ok) {
|
|
192
|
+
try {
|
|
193
|
+
return { ok: true, value: (await response.json()) as T };
|
|
194
|
+
} catch (error) {
|
|
195
|
+
return fail(
|
|
196
|
+
"unexpected",
|
|
197
|
+
`GitHub answered with an unreadable body: ${errorMessage(error)}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (response.status === 401) return fail("auth", "GitHub rejected the token (401).");
|
|
202
|
+
if (response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0") {
|
|
203
|
+
return fail("rate-limit", "GitHub API rate limit reached (403).");
|
|
204
|
+
}
|
|
205
|
+
if (response.status === 403 || response.status === 404) {
|
|
206
|
+
return fail(
|
|
207
|
+
"permission",
|
|
208
|
+
`GitHub refused the request (${response.status}); the token needs ${permission} on this repository.`,
|
|
209
|
+
{ permission },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return fail("unexpected", `GitHub answered ${response.status}.`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
id: "github",
|
|
217
|
+
async defaultBranch(repo) {
|
|
218
|
+
const result = await get<{ default_branch?: string }>(repoPath(repo), "Metadata: read");
|
|
219
|
+
if (!result.ok) return result;
|
|
220
|
+
const branch = result.value.default_branch;
|
|
221
|
+
return branch
|
|
222
|
+
? { ok: true, value: branch }
|
|
223
|
+
: {
|
|
224
|
+
ok: false,
|
|
225
|
+
failure: { reason: "unexpected", message: "GitHub named no default branch." },
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
async pullsForBranch(repo, branch, prNumber) {
|
|
229
|
+
const head = encodeURIComponent(`${repo.owner}:${branch}`);
|
|
230
|
+
const listPulls = (target: string) =>
|
|
231
|
+
get<GithubPull[]>(
|
|
232
|
+
`${target}/pulls?head=${head}&state=all&per_page=100`,
|
|
233
|
+
"Pull requests: read",
|
|
234
|
+
);
|
|
235
|
+
let listed = await listPulls(repoPath(repo));
|
|
236
|
+
if (!listed.ok) return listed;
|
|
237
|
+
let pullRepo = repoPath(repo);
|
|
238
|
+
if (listed.value.length === 0) {
|
|
239
|
+
// A fork's PR is opened on its parent; the head stays `<fork owner>:<branch>`.
|
|
240
|
+
const metadata = await get<{ parent?: { full_name?: string } }>(
|
|
241
|
+
repoPath(repo),
|
|
242
|
+
"Metadata: read",
|
|
243
|
+
);
|
|
244
|
+
const parent = metadata.ok ? metadata.value.parent?.full_name : undefined;
|
|
245
|
+
if (parent) {
|
|
246
|
+
pullRepo = `/repos/${parent.split("/").map(encodeURIComponent).join("/")}`;
|
|
247
|
+
listed = await listPulls(pullRepo);
|
|
248
|
+
if (!listed.ok) return listed;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const pulls =
|
|
252
|
+
prNumber === undefined
|
|
253
|
+
? listed.value
|
|
254
|
+
: listed.value.filter((entry) => entry.number === prNumber);
|
|
255
|
+
if (pulls.length === 0) return { ok: true, value: { kind: "none" } };
|
|
256
|
+
if (pulls.length > 1) {
|
|
257
|
+
return {
|
|
258
|
+
ok: true,
|
|
259
|
+
value: {
|
|
260
|
+
kind: "ambiguous",
|
|
261
|
+
candidates: pulls
|
|
262
|
+
.map((entry) => ({
|
|
263
|
+
number: entry.number,
|
|
264
|
+
url: entry.html_url,
|
|
265
|
+
state: candidateState(entry),
|
|
266
|
+
}))
|
|
267
|
+
.toSorted((a, b) => a.number - b.number),
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const [only] = pulls;
|
|
272
|
+
const state = candidateState(only);
|
|
273
|
+
if (state !== "merged") return { ok: true, value: { kind: state, pr: detail(only) } };
|
|
274
|
+
// The list omits the merger; only the single-PR endpoint carries it.
|
|
275
|
+
const full = await get<GithubPull>(`${pullRepo}/pulls/${only.number}`, "Pull requests: read");
|
|
276
|
+
return { ok: true, value: { kind: "merged", pr: detail(full.ok ? full.value : only) } };
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
activeChanges,
|
|
6
|
+
archiveAnchors,
|
|
7
|
+
changeNameOf,
|
|
8
|
+
changesDirOf,
|
|
9
|
+
deltaSpecs,
|
|
10
|
+
} from "../change-layout";
|
|
11
|
+
import { readFrontmatter } from "../frontmatter";
|
|
12
|
+
import { isMateSchema } from "./stages";
|
|
13
|
+
|
|
14
|
+
export interface RequirementEdit {
|
|
15
|
+
capability: string;
|
|
16
|
+
requirement: string;
|
|
17
|
+
operation: "MODIFIED" | "REMOVED";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DependencyWarning = { kind: "dependency"; change: string; message: string };
|
|
21
|
+
|
|
22
|
+
export type PlanningWarning = {
|
|
23
|
+
kind: "incomplete-planning";
|
|
24
|
+
change: string;
|
|
25
|
+
artifacts: string[];
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ArchiveWarning =
|
|
30
|
+
| { kind: "overlap"; change: string; capability: string; requirement: string; message: string }
|
|
31
|
+
| DependencyWarning
|
|
32
|
+
| PlanningWarning;
|
|
33
|
+
|
|
34
|
+
const SECTION = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/;
|
|
35
|
+
const REQUIREMENT = /^###\s+Requirement:\s*(.+?)\s*$/;
|
|
36
|
+
const TASK = /^\s*[-*]\s+\[([ xX])\]/;
|
|
37
|
+
|
|
38
|
+
/** Requirements a change's delta specs MODIFY or REMOVE, by full capability path. */
|
|
39
|
+
export async function deltaRequirementEdits(changeDir: string): Promise<RequirementEdit[]> {
|
|
40
|
+
const edits: RequirementEdit[] = [];
|
|
41
|
+
for (const { file, capability } of await deltaSpecs(changeDir)) {
|
|
42
|
+
if (capability === null) continue;
|
|
43
|
+
let source: string;
|
|
44
|
+
try {
|
|
45
|
+
source = await fs.readFile(path.join(changeDir, "specs", file), "utf8");
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
let section: string | null = null;
|
|
50
|
+
for (const line of source.split(/\r?\n/)) {
|
|
51
|
+
const heading = SECTION.exec(line);
|
|
52
|
+
if (heading) {
|
|
53
|
+
section = heading[1];
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (line.startsWith("## ")) section = null;
|
|
57
|
+
const requirement = REQUIREMENT.exec(line);
|
|
58
|
+
if (requirement && (section === "MODIFIED" || section === "REMOVED")) {
|
|
59
|
+
edits.push({ capability, requirement: requirement[1], operation: section });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return edits;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Change names listed under `dependsOn` in any of the change's artifact frontmatter. */
|
|
67
|
+
export async function declaredDependencies(changeDir: string): Promise<string[]> {
|
|
68
|
+
let files: string[];
|
|
69
|
+
try {
|
|
70
|
+
files = (await fs.readdir(changeDir)).filter((file) => file.endsWith(".md"));
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const names = new Set<string>();
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
// A malformed header declares nothing; validation reports it elsewhere.
|
|
77
|
+
const dependsOn = readFrontmatter(
|
|
78
|
+
await fs.readFile(path.join(changeDir, file), "utf8"),
|
|
79
|
+
)?.dependsOn;
|
|
80
|
+
for (const name of Array.isArray(dependsOn) ? dependsOn : [dependsOn]) {
|
|
81
|
+
if (typeof name === "string" && name.trim()) names.add(name.trim());
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return [...names].toSorted();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function archivedChangeNames(companionPath: string): Promise<Set<string>> {
|
|
88
|
+
return new Set((await archiveAnchors(companionPath)).map(changeNameOf));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True when the change's `tasks.md` has tasks and every one is checked. */
|
|
92
|
+
async function tasksComplete(changeDir: string): Promise<boolean> {
|
|
93
|
+
let source: string;
|
|
94
|
+
try {
|
|
95
|
+
source = await fs.readFile(path.join(changeDir, "tasks.md"), "utf8");
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const marks = source.split(/\r?\n/).flatMap((line) => TASK.exec(line)?.[1] ?? []);
|
|
100
|
+
return marks.length > 0 && marks.every((mark) => mark !== " ");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Warnings shown before applying `change`: declared dependencies neither archived nor fully
|
|
105
|
+
* implemented, so the change would be built without the code it builds on. Never blocks.
|
|
106
|
+
*/
|
|
107
|
+
export async function applyWarnings(
|
|
108
|
+
companionPath: string,
|
|
109
|
+
change: string,
|
|
110
|
+
): Promise<DependencyWarning[]> {
|
|
111
|
+
const changesDir = changesDirOf(companionPath);
|
|
112
|
+
const archived = await archivedChangeNames(companionPath);
|
|
113
|
+
const warnings: DependencyWarning[] = [];
|
|
114
|
+
for (const dependency of await declaredDependencies(path.join(changesDir, change))) {
|
|
115
|
+
if (archived.has(dependency) || (await tasksComplete(path.join(changesDir, dependency)))) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
warnings.push({
|
|
119
|
+
kind: "dependency",
|
|
120
|
+
change: dependency,
|
|
121
|
+
message: `${change} depends on ${dependency}, which is not implemented yet; apply ${dependency} first.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return warnings;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Warning for schema artifacts that `openspec status --change <change> --json` reports neither
|
|
129
|
+
* done nor skipped, e.g. a design missing after a `mate-minimal` change moved to `mate-v1`.
|
|
130
|
+
* Only Mate's schemas require every artifact; OpenSpec reports another schema's conditional
|
|
131
|
+
* artifacts (spec-driven's design) as `ready`, so those yield no warning, as does unparsable output.
|
|
132
|
+
*/
|
|
133
|
+
export function planningWarning(change: string, statusJson: string): PlanningWarning | null {
|
|
134
|
+
let status: { schemaName?: unknown; artifacts?: unknown };
|
|
135
|
+
try {
|
|
136
|
+
status = JSON.parse(statusJson) as typeof status;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const { schemaName, artifacts } = status ?? {};
|
|
141
|
+
if (typeof schemaName !== "string" || !isMateSchema(schemaName)) return null;
|
|
142
|
+
if (!Array.isArray(artifacts)) return null;
|
|
143
|
+
const missing = artifacts
|
|
144
|
+
.filter((artifact): artifact is { id: string; status?: unknown } => {
|
|
145
|
+
return typeof artifact?.id === "string";
|
|
146
|
+
})
|
|
147
|
+
.filter(({ status }) => status !== "done" && status !== "skipped")
|
|
148
|
+
.map(({ id }) => id);
|
|
149
|
+
if (missing.length === 0) return null;
|
|
150
|
+
return {
|
|
151
|
+
kind: "incomplete-planning",
|
|
152
|
+
change,
|
|
153
|
+
artifacts: missing,
|
|
154
|
+
message: `${change} is missing planning artifacts its schema requires: ${missing.join(", ")}.`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function requirementKey(edit: RequirementEdit): string {
|
|
159
|
+
return JSON.stringify([edit.capability, edit.requirement]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Warnings shown before archiving `change`: another active change MODIFYing or REMOVing a
|
|
164
|
+
* requirement this change also MODIFIES or REMOVES, and declared dependencies that are not
|
|
165
|
+
* archived yet. Warnings inform; they never block archive.
|
|
166
|
+
*/
|
|
167
|
+
export async function archiveWarnings(
|
|
168
|
+
companionPath: string,
|
|
169
|
+
change: string,
|
|
170
|
+
): Promise<ArchiveWarning[]> {
|
|
171
|
+
const changesDir = changesDirOf(companionPath);
|
|
172
|
+
const warnings: ArchiveWarning[] = [];
|
|
173
|
+
|
|
174
|
+
const own = await deltaRequirementEdits(path.join(changesDir, change));
|
|
175
|
+
if (own.length > 0) {
|
|
176
|
+
const ownKeys = new Set(own.map(requirementKey));
|
|
177
|
+
for (const other of await activeChanges(companionPath)) {
|
|
178
|
+
if (other === change) continue;
|
|
179
|
+
for (const edit of await deltaRequirementEdits(path.join(changesDir, other))) {
|
|
180
|
+
if (!ownKeys.has(requirementKey(edit))) continue;
|
|
181
|
+
warnings.push({
|
|
182
|
+
kind: "overlap",
|
|
183
|
+
change: other,
|
|
184
|
+
capability: edit.capability,
|
|
185
|
+
requirement: edit.requirement,
|
|
186
|
+
message: `${other} also changes ${edit.capability} › ${edit.requirement}; archiving ${other} later will replace this change's version of that requirement.`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const archived = await archivedChangeNames(companionPath);
|
|
193
|
+
for (const dependency of await declaredDependencies(path.join(changesDir, change))) {
|
|
194
|
+
if (archived.has(dependency)) continue;
|
|
195
|
+
warnings.push({
|
|
196
|
+
kind: "dependency",
|
|
197
|
+
change: dependency,
|
|
198
|
+
message: `${change} depends on ${dependency}, which is not archived yet; archive ${dependency} first.`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return warnings;
|
|
202
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { ProtectionReason } from "./branches";
|
|
2
|
+
import { locateChange, type ChangeLocation } from "../change-layout";
|
|
3
|
+
import { companionRelative, type DeliveryEnvironment } from "./environment";
|
|
4
|
+
import {
|
|
5
|
+
defaultDeliveryBranch,
|
|
6
|
+
findDelivery,
|
|
7
|
+
readOrUnlinkedRecord,
|
|
8
|
+
settleChangeDir,
|
|
9
|
+
upsertDelivery,
|
|
10
|
+
writeDeliveryRecord,
|
|
11
|
+
type DeliveryRecord,
|
|
12
|
+
type DeliverySettlement,
|
|
13
|
+
type MergeState,
|
|
14
|
+
} from "./record";
|
|
15
|
+
import {
|
|
16
|
+
refreshRecord,
|
|
17
|
+
type BranchOutcome,
|
|
18
|
+
type RefreshAccess,
|
|
19
|
+
type RefreshCache,
|
|
20
|
+
} from "./refresh";
|
|
21
|
+
import {
|
|
22
|
+
repositoryAccessFor,
|
|
23
|
+
type RepositoryAccess,
|
|
24
|
+
type RepositoryAccessDeps,
|
|
25
|
+
} from "./repository-access";
|
|
26
|
+
|
|
27
|
+
/** Edits to one repository's delivery, applied in this order around an optional refresh. */
|
|
28
|
+
export interface DeliveryEdit {
|
|
29
|
+
delivery?: { branch: string; direct: boolean } | { notApplicable: string };
|
|
30
|
+
/** Informational PR URL; also picks the PR a refresh asks about. */
|
|
31
|
+
pr?: string;
|
|
32
|
+
/** The user's answer, recorded after any refresh with source `user`. */
|
|
33
|
+
confirm?: MergeState;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DeliveryRequest {
|
|
37
|
+
changes: string[];
|
|
38
|
+
/**
|
|
39
|
+
* The delivery to edit, and the only one refreshed unless `refreshAll`. Without it the
|
|
40
|
+
* Working Repository's delivery is edited and every delivery is refreshed.
|
|
41
|
+
*/
|
|
42
|
+
repository?: string;
|
|
43
|
+
edit?: DeliveryEdit;
|
|
44
|
+
/** Ask the forge for each branch's merge state; a direct delivery needs the user's answer instead. */
|
|
45
|
+
refresh?: boolean;
|
|
46
|
+
/** Refresh every delivery even when `repository` names the one to edit. */
|
|
47
|
+
refreshAll?: boolean;
|
|
48
|
+
/** Archived changes may be edited; otherwise only active ones are found. */
|
|
49
|
+
includeArchived?: boolean;
|
|
50
|
+
/** A change left without any delivery answers `needsBranch` and nothing is written. */
|
|
51
|
+
requireDelivery?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type DeliveryRefusal =
|
|
55
|
+
| { reason: "unknown-change"; change: string }
|
|
56
|
+
| { reason: "no-repository" }
|
|
57
|
+
| { reason: "protected-branch"; branch: string; protection: ProtectionReason | null }
|
|
58
|
+
| {
|
|
59
|
+
reason: "no-delivery-branch";
|
|
60
|
+
change: string;
|
|
61
|
+
repository: string;
|
|
62
|
+
for: "pr" | "confirm";
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface DeliveryResult {
|
|
66
|
+
change: string;
|
|
67
|
+
/** Companion-relative path of the change directory. */
|
|
68
|
+
path: string;
|
|
69
|
+
archived: boolean;
|
|
70
|
+
record: DeliveryRecord;
|
|
71
|
+
settlement: DeliverySettlement;
|
|
72
|
+
/** Lookups made by the refresh, including the ones the user must answer. */
|
|
73
|
+
outcomes: BranchOutcome[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface NeedsBranch {
|
|
77
|
+
change: string;
|
|
78
|
+
record: DeliveryRecord;
|
|
79
|
+
/** `change/<change-name>` when that branch exists in the Working Repository. */
|
|
80
|
+
suggestedBranch: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type DeliveryOutcome =
|
|
84
|
+
| { refusal: DeliveryRefusal }
|
|
85
|
+
| { needsBranch: NeedsBranch }
|
|
86
|
+
/** Every record is already written when the request edited or refreshed. */
|
|
87
|
+
| { results: DeliveryResult[] };
|
|
88
|
+
|
|
89
|
+
export interface RecordDeliveryDeps extends RepositoryAccessDeps {
|
|
90
|
+
now?: () => Date;
|
|
91
|
+
repositoryAccess?: (repository: string) => RepositoryAccess;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hasDeliveryBranch(record: DeliveryRecord, repository: string): boolean {
|
|
95
|
+
return Boolean(findDelivery(record, repository)?.branch);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reads, edits, refreshes, and writes the Delivery Records of `request.changes`. Every
|
|
100
|
+
* change resolves before any record is written, so a refusal leaves no partial update; a
|
|
101
|
+
* request without an edit or refresh only reads. Network requests go only to the forge:
|
|
102
|
+
* for another repository's default branch, and to refresh. `--direct` is
|
|
103
|
+
* accepted on any branch; a protected one requires it.
|
|
104
|
+
*/
|
|
105
|
+
export async function recordDelivery(
|
|
106
|
+
environment: DeliveryEnvironment,
|
|
107
|
+
request: DeliveryRequest,
|
|
108
|
+
deps: RecordDeliveryDeps = {},
|
|
109
|
+
): Promise<DeliveryOutcome> {
|
|
110
|
+
const { edit = {} } = request;
|
|
111
|
+
const now = deps.now ?? (() => new Date());
|
|
112
|
+
const access = deps.repositoryAccess ?? repositoryAccessFor(environment, deps);
|
|
113
|
+
|
|
114
|
+
const located: Array<ChangeLocation & { change: string }> = [];
|
|
115
|
+
for (const change of request.changes) {
|
|
116
|
+
const location = await locateChange(environment.companionPath, change);
|
|
117
|
+
if (!location || (location.archived && !request.includeArchived)) {
|
|
118
|
+
return { refusal: { reason: "unknown-change", change } };
|
|
119
|
+
}
|
|
120
|
+
located.push({ ...location, change });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const repository = request.repository ?? environment.workingRepo?.slug ?? null;
|
|
124
|
+
const edits = edit.delivery !== undefined || edit.pr !== undefined || edit.confirm !== undefined;
|
|
125
|
+
if (edits && !repository) return { refusal: { reason: "no-repository" } };
|
|
126
|
+
if (repository && edit.delivery && "branch" in edit.delivery) {
|
|
127
|
+
const { branch, direct } = edit.delivery;
|
|
128
|
+
const protection = await access(repository).protection(branch);
|
|
129
|
+
if (protection.protected && !direct) {
|
|
130
|
+
return { refusal: { reason: "protected-branch", branch, protection: protection.reason } };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const refreshAccess: RefreshAccess = {
|
|
135
|
+
now,
|
|
136
|
+
forgeAccess: (repo) => access(repo).forge(),
|
|
137
|
+
};
|
|
138
|
+
const cache: RefreshCache = new Map();
|
|
139
|
+
const resolved: Array<DeliveryResult & { dir: string }> = [];
|
|
140
|
+
|
|
141
|
+
for (const { change, dir, archived } of located) {
|
|
142
|
+
let record = await readOrUnlinkedRecord(dir, change);
|
|
143
|
+
if (repository && edit.delivery) {
|
|
144
|
+
record = upsertDelivery(record, repository, edit.delivery);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (request.requireDelivery && record.deliveries.length === 0) {
|
|
148
|
+
const suggested = defaultDeliveryBranch(change);
|
|
149
|
+
const exists = (await environment.workingRepo?.git.branchExists(suggested)) ?? false;
|
|
150
|
+
return { needsBranch: { change, record, suggestedBranch: exists ? suggested : null } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (repository && edit.pr !== undefined) {
|
|
154
|
+
if (!hasDeliveryBranch(record, repository)) {
|
|
155
|
+
return { refusal: { reason: "no-delivery-branch", change, repository, for: "pr" } };
|
|
156
|
+
}
|
|
157
|
+
record = upsertDelivery(record, repository, { pr: edit.pr });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let outcomes: BranchOutcome[] = [];
|
|
161
|
+
if (request.refresh) {
|
|
162
|
+
const refreshed = await refreshRecord(record, {
|
|
163
|
+
...refreshAccess,
|
|
164
|
+
cache,
|
|
165
|
+
...(request.repository && !request.refreshAll ? { repository: request.repository } : {}),
|
|
166
|
+
});
|
|
167
|
+
record = refreshed.record;
|
|
168
|
+
outcomes = refreshed.outcomes;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (repository && edit.confirm !== undefined) {
|
|
172
|
+
if (!hasDeliveryBranch(record, repository)) {
|
|
173
|
+
return { refusal: { reason: "no-delivery-branch", change, repository, for: "confirm" } };
|
|
174
|
+
}
|
|
175
|
+
record = upsertDelivery(record, repository, {
|
|
176
|
+
merge: { state: edit.confirm, source: "user", checkedAt: now().toISOString() },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
resolved.push({
|
|
181
|
+
dir,
|
|
182
|
+
change: record.change,
|
|
183
|
+
path: companionRelative(environment, dir),
|
|
184
|
+
archived,
|
|
185
|
+
record,
|
|
186
|
+
settlement: await settleChangeDir(dir, record),
|
|
187
|
+
outcomes,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (edits || request.refresh) {
|
|
192
|
+
for (const { dir, record } of resolved) await writeDeliveryRecord(dir, record);
|
|
193
|
+
}
|
|
194
|
+
const results = resolved.map(({ dir: _dir, ...result }) => result);
|
|
195
|
+
return { results };
|
|
196
|
+
}
|