@shortlink-org/portolan 0.2.1 → 0.2.3
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/README.md +7 -2
- package/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +92 -13
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +14 -2
- package/scripts/catalog-sources.test.mjs +45 -0
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +330 -346
- package/scripts/local-api.test.mjs +245 -1
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +11 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +23 -8
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/app/toast.tsx +11 -7
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1339 -0
- package/src/catalog-validation.ts +1570 -0
- package/src/catalog.test.ts +49 -0
- package/src/catalog.ts +6 -3124
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.tsx +23 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +125 -37
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -17
- package/src/landing/LandingPage.tsx +16 -12
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/derive.ts +1 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +36 -11
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +101 -0
- package/src/merge.ts +65 -1
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +257 -44
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +3 -1
|
@@ -30,8 +30,12 @@ import { DraggableReveal } from "./DraggableReveal";
|
|
|
30
30
|
import { heroColumn, heroLine, Reveal } from "./motion";
|
|
31
31
|
import { ProductFrame } from "./ProductFrame";
|
|
32
32
|
import { ProductTour } from "./ProductTour";
|
|
33
|
+
import {
|
|
34
|
+
PRODUCT_LICENSE,
|
|
35
|
+
PRODUCT_README,
|
|
36
|
+
PRODUCT_REPOSITORY,
|
|
37
|
+
} from "../lib/product";
|
|
33
38
|
|
|
34
|
-
const REPOSITORY = "https://github.com/shortlink-org/portolan";
|
|
35
39
|
const EXAMPLE_DOCS = "https://shortlink-org.github.io/portolan/docs/example";
|
|
36
40
|
const exampleTo = catalogTo(paths.overview());
|
|
37
41
|
|
|
@@ -86,14 +90,14 @@ function Header() {
|
|
|
86
90
|
{theme === "dark" ? <Sun size={15} /> : <Moon size={15} />}
|
|
87
91
|
</button>
|
|
88
92
|
<a
|
|
89
|
-
href={
|
|
93
|
+
href={PRODUCT_REPOSITORY}
|
|
90
94
|
className="tbtn hidden sm:flex"
|
|
91
95
|
target="_blank"
|
|
92
96
|
rel="noreferrer"
|
|
93
97
|
>
|
|
94
98
|
<GitBranch size={15} /> GitHub
|
|
95
99
|
</a>
|
|
96
|
-
<Link to={exampleTo} className="
|
|
100
|
+
<Link to={exampleTo} className="landing-primary">
|
|
97
101
|
Explore example <ArrowRight size={14} />
|
|
98
102
|
</Link>
|
|
99
103
|
</div>
|
|
@@ -315,7 +319,7 @@ export function LandingPage() {
|
|
|
315
319
|
>
|
|
316
320
|
<Link
|
|
317
321
|
to={exampleTo}
|
|
318
|
-
className="
|
|
322
|
+
className="landing-primary px-4 py-2.5"
|
|
319
323
|
>
|
|
320
324
|
Explore example catalog <ArrowRight size={15} />
|
|
321
325
|
</Link>
|
|
@@ -362,7 +366,7 @@ export function LandingPage() {
|
|
|
362
366
|
<div className="shrink-0">
|
|
363
367
|
<div className="mono text-faint">BUILT-IN INPUTS</div>
|
|
364
368
|
<a
|
|
365
|
-
href={`${
|
|
369
|
+
href={`${PRODUCT_REPOSITORY}#plugins`}
|
|
366
370
|
target="_blank"
|
|
367
371
|
rel="noreferrer"
|
|
368
372
|
className="mt-1 inline-flex items-center gap-1 text-sm text-accent hover:underline"
|
|
@@ -643,12 +647,12 @@ export function LandingPage() {
|
|
|
643
647
|
<div className="mt-4 flex flex-wrap gap-3">
|
|
644
648
|
<Link
|
|
645
649
|
to={exampleTo}
|
|
646
|
-
className="
|
|
650
|
+
className="landing-primary px-4 py-2.5"
|
|
647
651
|
>
|
|
648
652
|
Open the example <ArrowRight size={15} />
|
|
649
653
|
</Link>
|
|
650
654
|
<a
|
|
651
|
-
href={`${
|
|
655
|
+
href={`${PRODUCT_REPOSITORY}#use-it-in-your-project`}
|
|
652
656
|
className="tbtn px-4 py-2.5 text-ink"
|
|
653
657
|
target="_blank"
|
|
654
658
|
rel="noreferrer"
|
|
@@ -715,12 +719,12 @@ export function LandingPage() {
|
|
|
715
719
|
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
|
716
720
|
<Link
|
|
717
721
|
to={exampleTo}
|
|
718
|
-
className="
|
|
722
|
+
className="landing-primary px-4 py-2.5"
|
|
719
723
|
>
|
|
720
724
|
Explore example catalog <ArrowRight size={15} />
|
|
721
725
|
</Link>
|
|
722
726
|
<a
|
|
723
|
-
href={
|
|
727
|
+
href={PRODUCT_REPOSITORY}
|
|
724
728
|
target="_blank"
|
|
725
729
|
rel="noreferrer"
|
|
726
730
|
className="tbtn px-4 py-2.5 text-ink"
|
|
@@ -737,16 +741,16 @@ export function LandingPage() {
|
|
|
737
741
|
<Wordmark />
|
|
738
742
|
<span className="sm:ml-3">Architecture from code and evidence.</span>
|
|
739
743
|
<div className="flex gap-5 sm:ml-auto">
|
|
740
|
-
<a className="hover:text-ink" href={
|
|
744
|
+
<a className="hover:text-ink" href={PRODUCT_README}>
|
|
741
745
|
Docs
|
|
742
746
|
</a>
|
|
743
747
|
<a
|
|
744
748
|
className="hover:text-ink"
|
|
745
|
-
href={
|
|
749
|
+
href={PRODUCT_LICENSE}
|
|
746
750
|
>
|
|
747
751
|
MIT License
|
|
748
752
|
</a>
|
|
749
|
-
<a className="hover:text-ink" href={
|
|
753
|
+
<a className="hover:text-ink" href={PRODUCT_REPOSITORY}>
|
|
750
754
|
GitHub
|
|
751
755
|
</a>
|
|
752
756
|
</div>
|
package/src/lib/all-problems.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Every reading the catalog makes of itself, in one list.
|
|
2
2
|
//
|
|
3
3
|
// Four readers, each answering a different question: edges that resolve to
|
|
4
|
-
// nothing,
|
|
4
|
+
// nothing, producer and consumer schemas that disagree, tables and columns
|
|
5
5
|
// that disagree with the model, and channels that documents and events do not
|
|
6
6
|
// agree on. The problems page, the overview and the landing all want the
|
|
7
7
|
// union, errors first - a boundary leak is not the same kind of news as a
|
package/src/lib/api.test.ts
CHANGED
|
@@ -23,15 +23,13 @@ function auth(): Service {
|
|
|
23
23
|
|
|
24
24
|
describe("interfaceDeclaring", () => {
|
|
25
25
|
it("finds the interface a method belongs to", () => {
|
|
26
|
-
expect(interfaceDeclaring(auth(), "registerUser")?.id).toBe(
|
|
27
|
-
|
|
28
|
-
);
|
|
29
|
-
expect(interfaceDeclaring(auth(), "login")?.id).toBe("auth.v1.Sessions");
|
|
26
|
+
expect(interfaceDeclaring(auth(), "registerUser")?.id).toBe("auth.v1");
|
|
27
|
+
expect(interfaceDeclaring(auth(), "login")?.id).toBe("auth.v1");
|
|
30
28
|
expect(interfaceDeclaring(auth(), "nothing")).toBeUndefined();
|
|
31
29
|
});
|
|
32
30
|
|
|
33
31
|
it("writes a method the way the rest of the app writes one", () => {
|
|
34
|
-
expect(methodId(auth(), "registerUser")).toBe("auth.v1
|
|
32
|
+
expect(methodId(auth(), "registerUser")).toBe("auth.v1/registerUser");
|
|
35
33
|
});
|
|
36
34
|
});
|
|
37
35
|
|
|
@@ -92,7 +90,7 @@ describe("methods, now that a method is more than a name", () => {
|
|
|
92
90
|
const declared = declaredMethods(auth());
|
|
93
91
|
|
|
94
92
|
expect(declared.length).toBe(methodCount(auth()));
|
|
95
|
-
expect(declared[0]?.provided.id).toBe("auth.v1
|
|
93
|
+
expect(declared[0]?.provided.id).toBe("auth.v1");
|
|
96
94
|
expect(declared.map((d) => d.method.name)).toContain("login");
|
|
97
95
|
});
|
|
98
96
|
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
buildHref,
|
|
4
4
|
buildLabel,
|
|
5
5
|
buildTitle,
|
|
6
|
+
repositoryCommitHref,
|
|
6
7
|
type BuildInfo,
|
|
7
8
|
} from "./build-info";
|
|
8
9
|
|
|
@@ -45,6 +46,34 @@ describe("buildHref", () => {
|
|
|
45
46
|
});
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
describe("repositoryCommitHref", () => {
|
|
50
|
+
it("links a GitLab commit through the repository page", () => {
|
|
51
|
+
expect(repositoryCommitHref("abc123", CI)).toBe(
|
|
52
|
+
"https://gitlab.com/acme/portolan/-/commit/abc123",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("links a GitHub commit through the repository page", () => {
|
|
57
|
+
expect(
|
|
58
|
+
repositoryCommitHref("abc123", {
|
|
59
|
+
...CI,
|
|
60
|
+
repoUrl: "https://github.com/acme/portolan/",
|
|
61
|
+
forge: "github",
|
|
62
|
+
}),
|
|
63
|
+
).toBe("https://github.com/acme/portolan/commit/abc123");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does not guess a route for an unknown forge", () => {
|
|
67
|
+
expect(
|
|
68
|
+
repositoryCommitHref("abc123", {
|
|
69
|
+
...CI,
|
|
70
|
+
repoUrl: "https://code.example.com/acme/portolan",
|
|
71
|
+
forge: undefined,
|
|
72
|
+
}),
|
|
73
|
+
).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
48
77
|
describe("buildLabel", () => {
|
|
49
78
|
it("reads as the commit, not the build number, even on CI", () => {
|
|
50
79
|
expect(buildLabel(CI)).toBe("4f1c9ae");
|
package/src/lib/build-info.ts
CHANGED
|
@@ -50,6 +50,27 @@ export function buildHref(info: BuildInfo = buildInfo): string | null {
|
|
|
50
50
|
return info.commitUrl || info.buildUrl || null;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** An immutable commit in the repository this bundle was built from. */
|
|
54
|
+
export function repositoryCommitHref(
|
|
55
|
+
commit: string,
|
|
56
|
+
info: BuildInfo = buildInfo,
|
|
57
|
+
): string | null {
|
|
58
|
+
if (!commit || !info.repoUrl) return null;
|
|
59
|
+
|
|
60
|
+
const repo = info.repoUrl.replace(/\/$/, "");
|
|
61
|
+
const forge =
|
|
62
|
+
info.forge ??
|
|
63
|
+
(/gitlab/i.test(repo)
|
|
64
|
+
? "gitlab"
|
|
65
|
+
: /github/i.test(repo)
|
|
66
|
+
? "github"
|
|
67
|
+
: null);
|
|
68
|
+
if (!forge) return null;
|
|
69
|
+
|
|
70
|
+
const route = forge === "gitlab" ? "/-/commit/" : "/commit/";
|
|
71
|
+
return `${repo}${route}${encodeURIComponent(commit)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
/**
|
|
54
75
|
* What the stamp reads: the commit, because that is the thing you can look up
|
|
55
76
|
* in the repo. The build number is CI bookkeeping and lives in the tooltip.
|
package/src/lib/data-model.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
Column,
|
|
16
16
|
ColumnOwner,
|
|
17
17
|
Field,
|
|
18
|
+
RedisKeyspace,
|
|
18
19
|
Service,
|
|
19
20
|
Store,
|
|
20
21
|
Table,
|
|
@@ -128,6 +129,19 @@ export interface PersistedTable {
|
|
|
128
129
|
store: Store;
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
export interface PersistedRedisKeyspace {
|
|
133
|
+
keyspace: RedisKeyspace;
|
|
134
|
+
store: Store;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Redis key families whose serialized value is an aggregate snapshot. */
|
|
138
|
+
export function redisKeyspacesPersisting(
|
|
139
|
+
index: CatalogIndex,
|
|
140
|
+
aggregateId: string,
|
|
141
|
+
): PersistedRedisKeyspace[] {
|
|
142
|
+
return index.keyspacesByAggregate.get(aggregateId) ?? [];
|
|
143
|
+
}
|
|
144
|
+
|
|
131
145
|
/**
|
|
132
146
|
* The tables that hold an aggregate — those naming it in `persists`, plus the
|
|
133
147
|
* ones naming one of its blocks. A child table pointing at a value object of
|
package/src/lib/derive.ts
CHANGED
package/src/lib/flow-tree.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function flowHealth(flow: Flow): FlowHealth {
|
|
|
42
42
|
const steps = walkSteps(flow.steps);
|
|
43
43
|
const statuses: Status[] = steps.map((s) => s.status);
|
|
44
44
|
if (statuses.some((s) => s === "unresolved")) return "unresolved";
|
|
45
|
-
const hops = steps.filter((s) => s.kind !== "call");
|
|
45
|
+
const hops = steps.filter((s) => s.kind !== "call" && s.kind !== "response");
|
|
46
46
|
if (hops.length > 0 && hops.every((s) => s.status === "verified"))
|
|
47
47
|
return "verified";
|
|
48
48
|
return "declared";
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
listForgeBranches,
|
|
7
7
|
listForgeRefs,
|
|
8
8
|
listGitHubBranches,
|
|
9
|
+
loadForgeCommit,
|
|
9
10
|
loadForgeCatalog,
|
|
10
11
|
loadGitHubCatalog,
|
|
11
12
|
} from "./github-catalog";
|
|
@@ -140,6 +141,56 @@ describe("listGitHubBranches", () => {
|
|
|
140
141
|
});
|
|
141
142
|
});
|
|
142
143
|
|
|
144
|
+
describe("loadForgeCommit", () => {
|
|
145
|
+
it("normalizes GitHub commit metadata for the hover card", async () => {
|
|
146
|
+
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
147
|
+
sha: SHA,
|
|
148
|
+
html_url: `${REPO.webUrl}/commit/${SHA}`,
|
|
149
|
+
commit: {
|
|
150
|
+
message: "Explain the decision\n\nKeep the useful detail.",
|
|
151
|
+
author: { name: "Ada", date: "2026-09-08T10:00:00Z" },
|
|
152
|
+
},
|
|
153
|
+
author: { login: "ada", avatar_url: "https://avatars.example/ada" },
|
|
154
|
+
stats: { additions: 12, deletions: 3 },
|
|
155
|
+
}), { status: 200 }));
|
|
156
|
+
vi.stubGlobal("fetch", fetch);
|
|
157
|
+
|
|
158
|
+
await expect(loadForgeCommit(REPO, SHA, { token: "secret" })).resolves.toEqual({
|
|
159
|
+
sha: SHA,
|
|
160
|
+
title: "Explain the decision",
|
|
161
|
+
body: "Keep the useful detail.",
|
|
162
|
+
author: "ada",
|
|
163
|
+
authoredAt: "2026-09-08T10:00:00Z",
|
|
164
|
+
avatarUrl: "https://avatars.example/ada",
|
|
165
|
+
additions: 12,
|
|
166
|
+
deletions: 3,
|
|
167
|
+
webUrl: `${REPO.webUrl}/commit/${SHA}`,
|
|
168
|
+
});
|
|
169
|
+
expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get("authorization")).toBe("Bearer secret");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("normalizes GitLab commit metadata for the same card", async () => {
|
|
173
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
174
|
+
id: SHA,
|
|
175
|
+
title: "Explain the decision",
|
|
176
|
+
message: "Explain the decision\n\nKeep the useful detail.",
|
|
177
|
+
author_name: "Ada",
|
|
178
|
+
authored_date: "2026-09-08T10:00:00Z",
|
|
179
|
+
web_url: `${GITLAB_REPO.webUrl}/-/commit/${SHA}`,
|
|
180
|
+
stats: { additions: 12, deletions: 3 },
|
|
181
|
+
}), { status: 200 })));
|
|
182
|
+
|
|
183
|
+
await expect(loadForgeCommit(GITLAB_REPO, SHA)).resolves.toMatchObject({
|
|
184
|
+
sha: SHA,
|
|
185
|
+
title: "Explain the decision",
|
|
186
|
+
body: "Keep the useful detail.",
|
|
187
|
+
author: "Ada",
|
|
188
|
+
additions: 12,
|
|
189
|
+
deletions: 3,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
143
194
|
describe("loadGitHubCatalog", () => {
|
|
144
195
|
it("discovers manifest sources at an immutable SHA and validates the merge", async () => {
|
|
145
196
|
const source = {
|
|
@@ -33,6 +33,18 @@ export type ForgeRepo = GitHubRepo | GitLabRepo;
|
|
|
33
33
|
|
|
34
34
|
export type ForgeAccess = { token?: string };
|
|
35
35
|
|
|
36
|
+
export type ForgeCommit = {
|
|
37
|
+
sha: string;
|
|
38
|
+
title: string;
|
|
39
|
+
body: string;
|
|
40
|
+
author: string;
|
|
41
|
+
authoredAt: string;
|
|
42
|
+
avatarUrl: string;
|
|
43
|
+
additions: number | null;
|
|
44
|
+
deletions: number | null;
|
|
45
|
+
webUrl: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
36
48
|
/**
|
|
37
49
|
* A name the forge resolves to a commit: a branch head as it is now, or a
|
|
38
50
|
* tag. Both are what a reader compares against; the kind is kept because a
|
|
@@ -263,6 +275,63 @@ function gitlabError(response: Response): Error {
|
|
|
263
275
|
return new Error(`GitLab API request failed (${response.status}).`);
|
|
264
276
|
}
|
|
265
277
|
|
|
278
|
+
/** Commit metadata for a hover card, normalized across GitHub and GitLab. */
|
|
279
|
+
export async function loadForgeCommit(
|
|
280
|
+
repo: ForgeRepo,
|
|
281
|
+
sha: string,
|
|
282
|
+
access: ForgeAccess = {},
|
|
283
|
+
): Promise<ForgeCommit> {
|
|
284
|
+
if (repo.provider === "github") {
|
|
285
|
+
const found = await githubJson<{
|
|
286
|
+
sha: string;
|
|
287
|
+
html_url: string;
|
|
288
|
+
commit: {
|
|
289
|
+
message: string;
|
|
290
|
+
author: { name: string; date: string } | null;
|
|
291
|
+
};
|
|
292
|
+
author: { login: string; avatar_url: string } | null;
|
|
293
|
+
stats?: { additions: number; deletions: number };
|
|
294
|
+
}>(apiUrl(repo, `/commits/${encodeURIComponent(sha)}`), access.token);
|
|
295
|
+
const [title = found.sha.slice(0, 7), ...body] = found.commit.message.split("\n");
|
|
296
|
+
return {
|
|
297
|
+
sha: found.sha,
|
|
298
|
+
title,
|
|
299
|
+
body: body.join("\n").trim(),
|
|
300
|
+
author: found.author?.login || found.commit.author?.name || "unknown",
|
|
301
|
+
authoredAt: found.commit.author?.date || "",
|
|
302
|
+
avatarUrl: found.author?.avatar_url || "",
|
|
303
|
+
additions: found.stats?.additions ?? null,
|
|
304
|
+
deletions: found.stats?.deletions ?? null,
|
|
305
|
+
webUrl: found.html_url,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const found = await gitlabJson<{
|
|
310
|
+
id: string;
|
|
311
|
+
title: string;
|
|
312
|
+
message: string;
|
|
313
|
+
author_name: string;
|
|
314
|
+
authored_date: string;
|
|
315
|
+
web_url: string;
|
|
316
|
+
stats?: { additions: number; deletions: number };
|
|
317
|
+
}>(
|
|
318
|
+
gitlabApiUrl(repo, `/repository/commits/${encodeURIComponent(sha)}`),
|
|
319
|
+
access.token,
|
|
320
|
+
);
|
|
321
|
+
const body = found.message.slice(found.title.length).trim();
|
|
322
|
+
return {
|
|
323
|
+
sha: found.id,
|
|
324
|
+
title: found.title,
|
|
325
|
+
body,
|
|
326
|
+
author: found.author_name || "unknown",
|
|
327
|
+
authoredAt: found.authored_date || "",
|
|
328
|
+
avatarUrl: "",
|
|
329
|
+
additions: found.stats?.additions ?? null,
|
|
330
|
+
deletions: found.stats?.deletions ?? null,
|
|
331
|
+
webUrl: found.web_url,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
266
335
|
async function fetchText(
|
|
267
336
|
url: string,
|
|
268
337
|
label: string,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { RpcMethod, RpcService } from "../catalog";
|
|
3
|
+
import { grpcRequestJson, grpcurlCommand } from "./grpc-reference";
|
|
4
|
+
|
|
5
|
+
const method: RpcMethod = {
|
|
6
|
+
name: "Create",
|
|
7
|
+
request: "CreateRequest",
|
|
8
|
+
response: "CreateResponse",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const provided: RpcService = {
|
|
12
|
+
id: "shop.v1.Orders",
|
|
13
|
+
source: "proto/shop/v1/orders.proto:10",
|
|
14
|
+
methods: [method],
|
|
15
|
+
enums: [
|
|
16
|
+
{
|
|
17
|
+
name: "Priority",
|
|
18
|
+
values: [
|
|
19
|
+
{ name: "PRIORITY_UNSPECIFIED", number: 0 },
|
|
20
|
+
{ name: "PRIORITY_HIGH", number: 1 },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
messages: [
|
|
25
|
+
{
|
|
26
|
+
name: "CreateRequest",
|
|
27
|
+
fields: [
|
|
28
|
+
{ name: "name", type: "string", doc: "" },
|
|
29
|
+
{ name: "quantity", type: "int64", doc: "" },
|
|
30
|
+
{ name: "expedited", type: "bool", doc: "" },
|
|
31
|
+
{ name: "priority", type: "Priority", doc: "" },
|
|
32
|
+
{ name: "lines", type: "[]Line", doc: "" },
|
|
33
|
+
{ name: "when", type: "Timestamp", doc: "" },
|
|
34
|
+
{ name: "money", type: "Money", ref: "Money", doc: "" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{ name: "CreateResponse", fields: [] },
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("gRPC reference examples", () => {
|
|
42
|
+
it("builds a ProtoJSON request from scalars, enums, lists and shared defs", () => {
|
|
43
|
+
expect(JSON.parse(grpcRequestJson(provided, method, {
|
|
44
|
+
Money: {
|
|
45
|
+
fields: [
|
|
46
|
+
{ name: "currency", type: "string", doc: "" },
|
|
47
|
+
{ name: "minor", type: "int64", doc: "" },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
}))).toEqual({
|
|
51
|
+
name: "",
|
|
52
|
+
quantity: "0",
|
|
53
|
+
expedited: false,
|
|
54
|
+
priority: "PRIORITY_UNSPECIFIED",
|
|
55
|
+
lines: [],
|
|
56
|
+
when: "1970-01-01T00:00:00Z",
|
|
57
|
+
money: { currency: "", minor: "0" },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("emits a ready-to-edit grpcurl command", () => {
|
|
62
|
+
const command = grpcurlCommand(provided, method, {});
|
|
63
|
+
expect(command).toContain("grpcurl -plaintext");
|
|
64
|
+
expect(command).toContain("localhost:50051");
|
|
65
|
+
expect(command).toContain("shop.v1.Orders/Create");
|
|
66
|
+
expect(command).toContain(`"quantity": "0"`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("falls back to an empty body when no request shape was extracted", () => {
|
|
70
|
+
expect(grpcRequestJson(provided, { name: "Ping" }, {})).toBe("{}");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Small, executable examples for a gRPC method.
|
|
2
|
+
//
|
|
3
|
+
// The protobuf extractor already leaves the catalog with enough information
|
|
4
|
+
// to sketch a request. Keeping that derivation here means the reference UI
|
|
5
|
+
// does not need to understand scalar mappings, recursive messages or shell
|
|
6
|
+
// quoting, and it gives the generated command a focused test surface.
|
|
7
|
+
|
|
8
|
+
import type { Field, RpcMethod, RpcService, TypeDef } from "../catalog";
|
|
9
|
+
import { parseType } from "./shape";
|
|
10
|
+
|
|
11
|
+
const NUMBER = new Set([
|
|
12
|
+
"double",
|
|
13
|
+
"float",
|
|
14
|
+
"int32",
|
|
15
|
+
"sint32",
|
|
16
|
+
"sfixed32",
|
|
17
|
+
"uint32",
|
|
18
|
+
"fixed32",
|
|
19
|
+
]);
|
|
20
|
+
const LONG = new Set([
|
|
21
|
+
"int64",
|
|
22
|
+
"sint64",
|
|
23
|
+
"sfixed64",
|
|
24
|
+
"uint64",
|
|
25
|
+
"fixed64",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function shortName(name: string): string {
|
|
29
|
+
return name.split(".").at(-1) ?? name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fieldsFor(
|
|
33
|
+
provided: RpcService,
|
|
34
|
+
name: string,
|
|
35
|
+
ref: string | undefined,
|
|
36
|
+
defs: Record<string, TypeDef>,
|
|
37
|
+
): Field[] | null {
|
|
38
|
+
if (ref && defs[ref]) return defs[ref].fields;
|
|
39
|
+
const bare = shortName(name);
|
|
40
|
+
return (
|
|
41
|
+
provided.messages?.find(
|
|
42
|
+
(message) => message.name === name || message.name === bare,
|
|
43
|
+
)?.fields ?? null
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function valueFor(
|
|
48
|
+
provided: RpcService,
|
|
49
|
+
field: Field,
|
|
50
|
+
defs: Record<string, TypeDef>,
|
|
51
|
+
visiting: ReadonlySet<string>,
|
|
52
|
+
): unknown {
|
|
53
|
+
const parsed = parseType(field.type);
|
|
54
|
+
if (parsed.cardinality === "many") return [];
|
|
55
|
+
if (parsed.cardinality === "map") return {};
|
|
56
|
+
|
|
57
|
+
const type = shortName(parsed.base);
|
|
58
|
+
if (type === "string" || type === "bytes") return "";
|
|
59
|
+
if (type === "bool") return false;
|
|
60
|
+
if (NUMBER.has(type)) return 0;
|
|
61
|
+
// ProtoJSON writes 64-bit integers as decimal strings so their precision is
|
|
62
|
+
// not lost in JavaScript.
|
|
63
|
+
if (LONG.has(type)) return "0";
|
|
64
|
+
if (type === "Timestamp") return "1970-01-01T00:00:00Z";
|
|
65
|
+
if (type === "Duration") return "0s";
|
|
66
|
+
if (type === "FieldMask") return "";
|
|
67
|
+
if (type === "Struct") return {};
|
|
68
|
+
if (type === "Value") return null;
|
|
69
|
+
if (type === "ListValue") return [];
|
|
70
|
+
if (type === "Empty") return {};
|
|
71
|
+
if (type === "Any") return { "@type": "" };
|
|
72
|
+
|
|
73
|
+
const set = provided.enums?.find(
|
|
74
|
+
(candidate) => candidate.name === parsed.base || candidate.name === type,
|
|
75
|
+
);
|
|
76
|
+
if (set) return set.values[0]?.name ?? "";
|
|
77
|
+
|
|
78
|
+
const nested = fieldsFor(provided, parsed.base, field.ref, defs);
|
|
79
|
+
if (!nested || visiting.has(parsed.base)) return {};
|
|
80
|
+
|
|
81
|
+
const next = new Set(visiting).add(parsed.base);
|
|
82
|
+
return Object.fromEntries(
|
|
83
|
+
nested.map((child) => [child.name, valueFor(provided, child, defs, next)]),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A valid ProtoJSON-shaped starting point for a method request. */
|
|
88
|
+
export function grpcRequestJson(
|
|
89
|
+
provided: RpcService,
|
|
90
|
+
method: RpcMethod,
|
|
91
|
+
defs: Record<string, TypeDef>,
|
|
92
|
+
): string {
|
|
93
|
+
if (!method.request) return "{}";
|
|
94
|
+
const fields = fieldsFor(provided, method.request, method.requestRef, defs);
|
|
95
|
+
if (!fields) return "{}";
|
|
96
|
+
|
|
97
|
+
const visiting = new Set([method.request]);
|
|
98
|
+
const value = Object.fromEntries(
|
|
99
|
+
fields.map((field) => [field.name, valueFor(provided, field, defs, visiting)]),
|
|
100
|
+
);
|
|
101
|
+
return JSON.stringify(value, null, 2);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A copyable local-development invocation. The endpoint is deliberately visible. */
|
|
105
|
+
export function grpcurlCommand(
|
|
106
|
+
provided: RpcService,
|
|
107
|
+
method: RpcMethod,
|
|
108
|
+
defs: Record<string, TypeDef>,
|
|
109
|
+
): string {
|
|
110
|
+
const body = grpcRequestJson(provided, method, defs).replace(/'/g, `'"'"'`);
|
|
111
|
+
return [
|
|
112
|
+
"grpcurl -plaintext \\",
|
|
113
|
+
` -d '${body}' \\`,
|
|
114
|
+
" localhost:50051 \\",
|
|
115
|
+
` ${provided.id}/${method.name}`,
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|