@a5c-ai/krate 5.0.1-staging.04a3db697
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/Dockerfile +31 -0
- package/README.md +183 -0
- package/bin/krate-demo.mjs +23 -0
- package/bin/krate-server.mjs +14 -0
- package/dist/krate-controller-ui.json +3067 -0
- package/dist/krate-lifecycle.json +201 -0
- package/dist/krate-runtime-snapshot.json +2955 -0
- package/dist/krate-summary.json +722 -0
- package/docs/README.md +61 -0
- package/docs/agents/README.md +83 -0
- package/docs/agents/acceptance-test-matrix.md +193 -0
- package/docs/agents/agent-mux-adapter-contract.md +167 -0
- package/docs/agents/agent-mux-source-map.md +310 -0
- package/docs/agents/agent-run-memory-import-spec.md +256 -0
- package/docs/agents/agent-stack-management-spec.md +421 -0
- package/docs/agents/api-contract-spec.md +309 -0
- package/docs/agents/artifacts-writeback-spec.md +145 -0
- package/docs/agents/chart-packaging-spec.md +128 -0
- package/docs/agents/ci-orchestration-spec.md +140 -0
- package/docs/agents/context-assembly-spec.md +219 -0
- package/docs/agents/controller-reconciliation-spec.md +255 -0
- package/docs/agents/crd-schema-spec.md +315 -0
- package/docs/agents/decision-log-open-questions.md +169 -0
- package/docs/agents/developer-implementation-checklist.md +329 -0
- package/docs/agents/dispatching-design.md +262 -0
- package/docs/agents/gaps-agent-mux-to-krate-crds.md +298 -0
- package/docs/agents/glossary.md +66 -0
- package/docs/agents/implementation-blueprint.md +324 -0
- package/docs/agents/implementation-rollout-slices.md +251 -0
- package/docs/agents/memory-context-integration-spec.md +194 -0
- package/docs/agents/memory-ontology-schema-spec.md +253 -0
- package/docs/agents/memory-operations-runbook.md +121 -0
- package/docs/agents/mvp-vertical-slice-spec.md +146 -0
- package/docs/agents/observability-audit-spec.md +265 -0
- package/docs/agents/operator-runbook.md +174 -0
- package/docs/agents/org-memory-api-payload-examples.md +333 -0
- package/docs/agents/org-memory-controller-sequence-spec.md +181 -0
- package/docs/agents/org-memory-e2e-fixture-plan.md +161 -0
- package/docs/agents/org-memory-ui-implementation-map.md +114 -0
- package/docs/agents/org-memory-vertical-slice-spec.md +168 -0
- package/docs/agents/org-resource-model-delta-spec.md +111 -0
- package/docs/agents/org-route-resource-model-spec.md +183 -0
- package/docs/agents/org-scoping-namespace-spec.md +114 -0
- package/docs/agents/rbac-secrets-management-spec.md +406 -0
- package/docs/agents/repository-page-integration-spec.md +255 -0
- package/docs/agents/resource-contract-examples.md +808 -0
- package/docs/agents/resource-relationship-map.md +190 -0
- package/docs/agents/security-threat-model.md +188 -0
- package/docs/agents/shared-memory-company-brain-spec.md +358 -0
- package/docs/agents/storage-migration-spec.md +168 -0
- package/docs/agents/subagent-orchestration-spec.md +152 -0
- package/docs/agents/system-overview.md +88 -0
- package/docs/agents/tools-mcp-skills-spec.md +189 -0
- package/docs/agents/traceability-matrix.md +79 -0
- package/docs/agents/ui-flow-spec.md +211 -0
- package/docs/agents/ui-ux-system-spec.md +426 -0
- package/docs/agents/workspace-lifecycle-spec.md +166 -0
- package/docs/architecture-spec.md +78 -0
- package/docs/components/control-plane.md +78 -0
- package/docs/components/data-plane.md +69 -0
- package/docs/components/hooks-events.md +67 -0
- package/docs/components/identity-rbac-policy.md +73 -0
- package/docs/components/kubevela-oam.md +70 -0
- package/docs/components/operations-publishing.md +81 -0
- package/docs/components/runners-ci.md +66 -0
- package/docs/components/web-ui.md +94 -0
- package/docs/external/README.md +47 -0
- package/docs/external/bidirectional-sync-design.md +134 -0
- package/docs/external/cicd-interface.md +64 -0
- package/docs/external/external-backend-controllers.md +170 -0
- package/docs/external/external-backend-crds.md +234 -0
- package/docs/external/external-backend-ui-spec.md +151 -0
- package/docs/external/external-backend-ux-flows.md +115 -0
- package/docs/external/external-object-mapping.md +125 -0
- package/docs/external/git-forge-interface.md +68 -0
- package/docs/external/github-integration-design.md +151 -0
- package/docs/external/issue-tracking-interface.md +66 -0
- package/docs/external/provider-capability-manifests.md +204 -0
- package/docs/external/provider-catalog.md +139 -0
- package/docs/external/provider-rollout-testing.md +78 -0
- package/docs/external/research-results.md +48 -0
- package/docs/external/security-auth-permissions.md +81 -0
- package/docs/external/sync-state-machines.md +108 -0
- package/docs/external/unified-external-backend-model.md +107 -0
- package/docs/external/user-facing-changes.md +67 -0
- package/docs/gaps.md +161 -0
- package/docs/install.md +94 -0
- package/docs/krate-design.md +334 -0
- package/docs/local-minikube.md +55 -0
- package/docs/ontology/README.md +32 -0
- package/docs/ontology/bounded-contexts.md +29 -0
- package/docs/ontology/events-and-hooks.md +32 -0
- package/docs/ontology/oam-kubevela.md +32 -0
- package/docs/ontology/operations-and-release.md +25 -0
- package/docs/ontology/personas-and-actors.md +32 -0
- package/docs/ontology/policies-and-invariants.md +33 -0
- package/docs/ontology/problem-space.md +30 -0
- package/docs/ontology/resource-contracts.md +40 -0
- package/docs/ontology/resource-taxonomy.md +42 -0
- package/docs/ontology/runners-and-ci.md +29 -0
- package/docs/ontology/solution-space.md +24 -0
- package/docs/ontology/storage-and-data-boundaries.md +29 -0
- package/docs/ontology/validation-matrix.md +24 -0
- package/docs/ontology/web-ui-excellent-flows.md +32 -0
- package/docs/ontology/workflows.md +39 -0
- package/docs/ontology/world.md +35 -0
- package/docs/product-requirements.md +62 -0
- package/docs/roadmap-mvp.md +87 -0
- package/docs/system-requirements.md +90 -0
- package/docs/tests/README.md +53 -0
- package/docs/tests/agent-qa-plan.md +63 -0
- package/docs/tests/browser-ui-tests.md +62 -0
- package/docs/tests/ci-quality-gates.md +48 -0
- package/docs/tests/coverage-model.md +64 -0
- package/docs/tests/e2e-scenario-tests.md +53 -0
- package/docs/tests/fixtures-test-data.md +63 -0
- package/docs/tests/observability-reliability-tests.md +54 -0
- package/docs/tests/product-test-matrix.md +145 -0
- package/docs/tests/qa-adoption-roadmap.md +130 -0
- package/docs/tests/qa-automation-plan.md +101 -0
- package/docs/tests/security-compliance-tests.md +57 -0
- package/docs/tests/test-framework-tools.md +88 -0
- package/docs/tests/test-suite-layout.md +121 -0
- package/docs/tests/unit-integration-tests.md +48 -0
- package/docs/todo-kyverno +714 -0
- package/docs/todos.md +4 -0
- package/docs/user-stories.md +78 -0
- package/examples/minikube-demo.yaml +190 -0
- package/examples/oam-application.yaml +23 -0
- package/examples/policy-kyverno-pr-title.yaml +18 -0
- package/package.json +63 -0
- package/scripts/build.mjs +29 -0
- package/scripts/setup-minikube.mjs +65 -0
- package/scripts/smoke.mjs +37 -0
- package/scripts/validate-doc-coverage.mjs +152 -0
- package/scripts/validate-package.mjs +93 -0
- package/scripts/validate-ui.mjs +236 -0
- package/src/agent-adapter-controller.js +169 -0
- package/src/agent-approval-controller.js +170 -0
- package/src/agent-context-bundles.js +242 -0
- package/src/agent-dispatch-controller.js +209 -0
- package/src/agent-gateway-config-controller.js +147 -0
- package/src/agent-memory-controller.js +357 -0
- package/src/agent-memory-import.js +327 -0
- package/src/agent-memory-query.js +292 -0
- package/src/agent-memory-repository-source-controller.js +255 -0
- package/src/agent-mux-client.js +280 -0
- package/src/agent-permission-review.js +250 -0
- package/src/agent-project-controller.js +117 -0
- package/src/agent-provider-config-controller.js +150 -0
- package/src/agent-secret-config-grant-controller.js +282 -0
- package/src/agent-session-transcript-controller.js +189 -0
- package/src/agent-stack-controller.js +347 -0
- package/src/agent-subagent-controller.js +160 -0
- package/src/agent-transport-binding-controller.js +121 -0
- package/src/agent-trigger-controller.js +321 -0
- package/src/agent-workspace-controller.js +447 -0
- package/src/agent-writeback-controller.js +302 -0
- package/src/api-controller.js +541 -0
- package/src/argocd-gitops.js +43 -0
- package/src/async-controller.js +207 -0
- package/src/audit-controller.js +191 -0
- package/src/auth.js +307 -0
- package/src/component-catalog.js +41 -0
- package/src/control-plane.js +136 -0
- package/src/controller-client.js +50 -0
- package/src/controller-ui.js +551 -0
- package/src/data-plane.js +178 -0
- package/src/event-bus.js +61 -0
- package/src/external/conflict-controller.js +225 -0
- package/src/external/github/auth.js +96 -0
- package/src/external/github/cicd.js +180 -0
- package/src/external/github/git-forge.js +240 -0
- package/src/external/github/index.js +144 -0
- package/src/external/github/issue-tracking.js +163 -0
- package/src/external/provider-adapter.js +161 -0
- package/src/external/provider-resource-factory.js +161 -0
- package/src/external/sync-controller.js +235 -0
- package/src/external/webhook-controller.js +144 -0
- package/src/external/write-controller.js +283 -0
- package/src/gitea-backend.js +95 -0
- package/src/gitea-service.js +173 -0
- package/src/handoff.js +98 -0
- package/src/hooks-events.js +63 -0
- package/src/http-server.js +377 -0
- package/src/identity-policy.js +86 -0
- package/src/index.js +55 -0
- package/src/kubernetes-controller-async.js +511 -0
- package/src/kubernetes-controller.js +878 -0
- package/src/kubernetes-resource-gateway.js +48 -0
- package/src/operations.js +112 -0
- package/src/org-scoping.js +5 -0
- package/src/resource-model.js +221 -0
- package/src/runners-ci.js +48 -0
- package/src/runtime.js +196 -0
- package/src/snapshot-cache.js +157 -0
- package/src/web-ui.js +40 -0
- package/tests/agent-adapter-controller.test.js +361 -0
- package/tests/agent-approval-controller.test.js +173 -0
- package/tests/agent-context-bundles.test.js +278 -0
- package/tests/agent-dispatch-controller.test.js +315 -0
- package/tests/agent-gateway-config-controller.test.js +386 -0
- package/tests/agent-memory-controller.test.js +308 -0
- package/tests/agent-memory-import-snapshot.test.js +477 -0
- package/tests/agent-memory-query.test.js +404 -0
- package/tests/agent-memory-repository-source.test.js +514 -0
- package/tests/agent-mux-client.test.js +204 -0
- package/tests/agent-permission-review-v2.test.js +317 -0
- package/tests/agent-permission-review.test.js +209 -0
- package/tests/agent-project-controller.test.js +302 -0
- package/tests/agent-provider-config-controller.test.js +376 -0
- package/tests/agent-resources.test.js +228 -0
- package/tests/agent-secret-config-grant.test.js +231 -0
- package/tests/agent-session-transcript-controller.test.js +499 -0
- package/tests/agent-stack-controller.test.js +221 -0
- package/tests/agent-subagent-controller.test.js +201 -0
- package/tests/agent-transport-binding-controller.test.js +294 -0
- package/tests/agent-trigger-controller.test.js +211 -0
- package/tests/agent-trigger-routes.test.js +190 -0
- package/tests/agent-trigger-sources.test.js +245 -0
- package/tests/agent-workspace-controller.test.js +181 -0
- package/tests/agent-writeback.test.js +292 -0
- package/tests/approval-persistence.test.js +171 -0
- package/tests/async-controller.test.js +252 -0
- package/tests/audit-controller.test.js +227 -0
- package/tests/deployment.test.js +396 -0
- package/tests/e2e/lifecycle.test.js +117 -0
- package/tests/external-github-forge.test.js +560 -0
- package/tests/external-github-issues-cicd.test.js +520 -0
- package/tests/external-integration.test.js +470 -0
- package/tests/external-persistence.test.js +340 -0
- package/tests/external-provider-adapter.test.js +365 -0
- package/tests/external-resource-model.test.js +215 -0
- package/tests/external-webhook-sync.test.js +287 -0
- package/tests/external-write-conflict.test.js +353 -0
- package/tests/gitea-service.test.js +253 -0
- package/tests/health-check-real.test.js +165 -0
- package/tests/integration/full-flow.test.js +266 -0
- package/tests/krate.test.js +727 -0
- package/tests/memory-search-wiring.test.js +270 -0
- package/tests/org-scoping.test.js +687 -0
- package/tests/session-cookie-hmac.test.js +151 -0
- package/tests/snapshot-performance.test.js +247 -0
- package/tests/sse-events.test.js +107 -0
- package/tests/workspace-volumes.test.js +312 -0
- package/tests/writeback-persistence.test.js +207 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
createGitHubJwt,
|
|
5
|
+
exchangeInstallationToken,
|
|
6
|
+
GitHubGitForge,
|
|
7
|
+
createGitHubProvider,
|
|
8
|
+
GITHUB_GIT_FORGE_BOUNDARY
|
|
9
|
+
} from '../src/external/github/index.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Test Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function makeMockFetch(responses) {
|
|
16
|
+
let callIndex = 0;
|
|
17
|
+
return async function mockFetch(url, options = {}) {
|
|
18
|
+
const entry = Array.isArray(responses) ? responses[callIndex++] : responses;
|
|
19
|
+
if (!entry) throw new Error(`Unexpected fetch call to ${url}`);
|
|
20
|
+
return {
|
|
21
|
+
ok: entry.status >= 200 && entry.status < 300,
|
|
22
|
+
status: entry.status ?? 200,
|
|
23
|
+
json: async () => entry.body,
|
|
24
|
+
text: async () => JSON.stringify(entry.body)
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeForge(fetchResponses = []) {
|
|
30
|
+
return new GitHubGitForge({
|
|
31
|
+
owner: 'my-org',
|
|
32
|
+
installationToken: 'ghs_test_token',
|
|
33
|
+
fetchImpl: makeMockFetch(fetchResponses)
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// 1. createGitHubJwt — generates a valid JWT structure (header.payload.signature)
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
describe('createGitHubJwt — structure', () => {
|
|
42
|
+
it('generates a string with three dot-separated parts', async () => {
|
|
43
|
+
const jwt = await createGitHubJwt({ appId: '12345', privateKey: 'hmac-test-key' });
|
|
44
|
+
const parts = jwt.split('.');
|
|
45
|
+
assert.equal(parts.length, 3, 'JWT must have exactly three parts separated by dots');
|
|
46
|
+
parts.forEach((part, i) => {
|
|
47
|
+
assert.ok(part.length > 0, `JWT part ${i} must not be empty`);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('header decodes to valid JSON with alg and typ', async () => {
|
|
52
|
+
const jwt = await createGitHubJwt({ appId: '12345', privateKey: 'hmac-test-key' });
|
|
53
|
+
const [headerB64] = jwt.split('.');
|
|
54
|
+
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8'));
|
|
55
|
+
assert.ok(header.alg, 'header must contain alg claim');
|
|
56
|
+
assert.equal(header.typ, 'JWT', 'header.typ must be "JWT"');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('payload decodes to valid JSON', async () => {
|
|
60
|
+
const jwt = await createGitHubJwt({ appId: '12345', privateKey: 'hmac-test-key' });
|
|
61
|
+
const [, payloadB64] = jwt.split('.');
|
|
62
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
|
|
63
|
+
assert.ok(typeof payload === 'object' && payload !== null, 'payload must be a JSON object');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// 2. createGitHubJwt — includes iss (app ID) and exp (10 min) claims
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe('createGitHubJwt — claims', () => {
|
|
72
|
+
it('includes iss claim equal to appId', async () => {
|
|
73
|
+
const appId = 'app-999';
|
|
74
|
+
const jwt = await createGitHubJwt({ appId, privateKey: 'hmac-test-key' });
|
|
75
|
+
const [, payloadB64] = jwt.split('.');
|
|
76
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
|
|
77
|
+
assert.equal(String(payload.iss), appId, 'iss claim must equal the appId');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('includes iat claim set to approximately now (seconds)', async () => {
|
|
81
|
+
const before = Math.floor(Date.now() / 1000);
|
|
82
|
+
const jwt = await createGitHubJwt({ appId: '1', privateKey: 'hmac-test-key' });
|
|
83
|
+
const after = Math.floor(Date.now() / 1000);
|
|
84
|
+
const [, payloadB64] = jwt.split('.');
|
|
85
|
+
const { iat } = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
|
|
86
|
+
assert.ok(iat >= before - 1 && iat <= after + 1, `iat (${iat}) must be within 1 second of now`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('includes exp claim set to approximately 10 minutes after iat', async () => {
|
|
90
|
+
const jwt = await createGitHubJwt({ appId: '1', privateKey: 'hmac-test-key' });
|
|
91
|
+
const [, payloadB64] = jwt.split('.');
|
|
92
|
+
const { iat, exp } = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
|
|
93
|
+
const diff = exp - iat;
|
|
94
|
+
assert.ok(diff >= 590 && diff <= 610, `exp - iat (${diff}s) must be approximately 600 seconds`);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// 3. createGitHubJwt — rejects missing appId or privateKey
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe('createGitHubJwt — validation', () => {
|
|
103
|
+
it('throws when appId is missing', async () => {
|
|
104
|
+
await assert.rejects(
|
|
105
|
+
() => createGitHubJwt({ privateKey: 'some-key' }),
|
|
106
|
+
/appId/i,
|
|
107
|
+
'must throw mentioning appId when appId is absent'
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws when privateKey is missing', async () => {
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
() => createGitHubJwt({ appId: '123' }),
|
|
114
|
+
/privateKey/i,
|
|
115
|
+
'must throw mentioning privateKey when privateKey is absent'
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws when both appId and privateKey are missing', async () => {
|
|
120
|
+
await assert.rejects(
|
|
121
|
+
() => createGitHubJwt({}),
|
|
122
|
+
/appId|privateKey/i,
|
|
123
|
+
'must throw when both appId and privateKey are absent'
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// 4. exchangeInstallationToken — calls correct GitHub API endpoint
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
describe('exchangeInstallationToken — API endpoint', () => {
|
|
133
|
+
it('calls the correct installation access token endpoint', async () => {
|
|
134
|
+
const calls = [];
|
|
135
|
+
const mockFetch = async (url, options) => {
|
|
136
|
+
calls.push({ url, options });
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
status: 201,
|
|
140
|
+
json: async () => ({ token: 'ghs_live_token', expires_at: '2025-01-01T00:10:00Z' })
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
await exchangeInstallationToken({
|
|
145
|
+
appJwt: 'fake.jwt.token',
|
|
146
|
+
installationId: '42',
|
|
147
|
+
fetchImpl: mockFetch
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
assert.equal(calls.length, 1, 'exactly one HTTP call must be made');
|
|
151
|
+
assert.ok(
|
|
152
|
+
calls[0].url.includes('/app/installations/42/access_tokens'),
|
|
153
|
+
`URL must include /app/installations/42/access_tokens, got: ${calls[0].url}`
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('sends Authorization header with Bearer JWT', async () => {
|
|
158
|
+
const calls = [];
|
|
159
|
+
const mockFetch = async (url, options) => {
|
|
160
|
+
calls.push({ url, options });
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
status: 201,
|
|
164
|
+
json: async () => ({ token: 'ghs_x', expires_at: '2025-01-01T00:10:00Z' })
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
await exchangeInstallationToken({
|
|
169
|
+
appJwt: 'my.jwt.here',
|
|
170
|
+
installationId: '7',
|
|
171
|
+
fetchImpl: mockFetch
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const authHeader = calls[0].options?.headers?.Authorization;
|
|
175
|
+
assert.ok(authHeader, 'Authorization header must be present');
|
|
176
|
+
assert.ok(authHeader.startsWith('Bearer '), 'Authorization header must use Bearer scheme');
|
|
177
|
+
assert.ok(authHeader.includes('my.jwt.here'), 'Authorization header must include the JWT');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// 5. exchangeInstallationToken — returns token and expiry
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
describe('exchangeInstallationToken — return value', () => {
|
|
186
|
+
it('returns an object with token and expiresAt fields', async () => {
|
|
187
|
+
const mockFetch = makeMockFetch({
|
|
188
|
+
status: 201,
|
|
189
|
+
body: { token: 'ghs_returned_token', expires_at: '2025-06-01T00:10:00Z' }
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const result = await exchangeInstallationToken({
|
|
193
|
+
appJwt: 'fake.jwt',
|
|
194
|
+
installationId: '99',
|
|
195
|
+
fetchImpl: mockFetch
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
assert.ok(result, 'result must be truthy');
|
|
199
|
+
assert.equal(result.token, 'ghs_returned_token', 'result.token must match API response');
|
|
200
|
+
assert.ok(result.expiresAt, 'result.expiresAt must be present');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('throws when GitHub API returns an error status', async () => {
|
|
204
|
+
const mockFetch = makeMockFetch({ status: 401, body: { message: 'Requires authentication' } });
|
|
205
|
+
|
|
206
|
+
await assert.rejects(
|
|
207
|
+
() => exchangeInstallationToken({ appJwt: 'bad.jwt', installationId: '1', fetchImpl: mockFetch }),
|
|
208
|
+
/401|authentication|token/i,
|
|
209
|
+
'must throw on non-2xx response'
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// 6. GitHubGitForge.listRepositories — returns normalized repository list
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
describe('GitHubGitForge.listRepositories', () => {
|
|
219
|
+
it('returns an array of normalized repository objects', async () => {
|
|
220
|
+
const forge = makeForge({
|
|
221
|
+
status: 200,
|
|
222
|
+
body: {
|
|
223
|
+
repositories: [
|
|
224
|
+
{ id: 1, name: 'repo-a', full_name: 'my-org/repo-a', private: true, default_branch: 'main', clone_url: 'https://github.com/my-org/repo-a.git' },
|
|
225
|
+
{ id: 2, name: 'repo-b', full_name: 'my-org/repo-b', private: false, default_branch: 'trunk', clone_url: 'https://github.com/my-org/repo-b.git' }
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const repos = await forge.listRepositories();
|
|
231
|
+
|
|
232
|
+
assert.ok(Array.isArray(repos), 'listRepositories must return an array');
|
|
233
|
+
assert.equal(repos.length, 2, 'must return both repositories');
|
|
234
|
+
assert.equal(repos[0].name, 'repo-a', 'first repo name must match');
|
|
235
|
+
assert.equal(repos[0].fullName, 'my-org/repo-a', 'fullName must be normalized from full_name');
|
|
236
|
+
assert.equal(repos[0].private, true, 'private flag must be present');
|
|
237
|
+
assert.equal(repos[0].defaultBranch, 'main', 'defaultBranch must be normalized from default_branch');
|
|
238
|
+
assert.ok(repos[0].cloneUrl, 'cloneUrl must be present');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// 7. GitHubGitForge.getPullRequest — returns normalized PR object
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
describe('GitHubGitForge.getPullRequest', () => {
|
|
247
|
+
it('returns a normalized PR object with id, title, state, head, base', async () => {
|
|
248
|
+
const forge = makeForge({
|
|
249
|
+
status: 200,
|
|
250
|
+
body: {
|
|
251
|
+
number: 42,
|
|
252
|
+
title: 'Add feature X',
|
|
253
|
+
state: 'open',
|
|
254
|
+
head: { ref: 'feature-x', sha: 'abc123' },
|
|
255
|
+
base: { ref: 'main', sha: 'def456' },
|
|
256
|
+
body: 'PR description',
|
|
257
|
+
merged: false,
|
|
258
|
+
html_url: 'https://github.com/my-org/repo/pull/42'
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const pr = await forge.getPullRequest({ repo: 'my-repo', pullNumber: 42 });
|
|
263
|
+
|
|
264
|
+
assert.ok(pr, 'getPullRequest must return a value');
|
|
265
|
+
assert.equal(pr.number, 42, 'PR number must match');
|
|
266
|
+
assert.equal(pr.title, 'Add feature X', 'PR title must match');
|
|
267
|
+
assert.equal(pr.state, 'open', 'PR state must match');
|
|
268
|
+
assert.equal(pr.head.ref, 'feature-x', 'head.ref must be present');
|
|
269
|
+
assert.equal(pr.base.ref, 'main', 'base.ref must be present');
|
|
270
|
+
assert.equal(pr.merged, false, 'merged flag must be present');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// 8. GitHubGitForge.createPullRequest — sends correct payload
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
describe('GitHubGitForge.createPullRequest', () => {
|
|
279
|
+
it('sends POST to correct endpoint with title, head, base, body', async () => {
|
|
280
|
+
const calls = [];
|
|
281
|
+
const forge = new GitHubGitForge({
|
|
282
|
+
owner: 'my-org',
|
|
283
|
+
installationToken: 'ghs_test',
|
|
284
|
+
fetchImpl: async (url, options) => {
|
|
285
|
+
calls.push({ url, method: options?.method, body: options?.body ? JSON.parse(options.body) : undefined });
|
|
286
|
+
return { ok: true, status: 201, json: async () => ({ number: 1, title: 'Test PR', state: 'open', head: { ref: 'feat', sha: 'aaa' }, base: { ref: 'main', sha: 'bbb' }, body: '', merged: false, html_url: '' }) };
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await forge.createPullRequest({ repo: 'my-repo', title: 'Test PR', head: 'feat-branch', base: 'main', body: 'PR body text' });
|
|
291
|
+
|
|
292
|
+
assert.equal(calls.length, 1, 'exactly one HTTP call must be made');
|
|
293
|
+
assert.equal(calls[0].method, 'POST', 'must use POST method');
|
|
294
|
+
assert.ok(calls[0].url.includes('/repos/my-org/my-repo/pulls'), `URL must target pulls endpoint, got ${calls[0].url}`);
|
|
295
|
+
assert.equal(calls[0].body.title, 'Test PR', 'payload must include title');
|
|
296
|
+
assert.equal(calls[0].body.head, 'feat-branch', 'payload must include head');
|
|
297
|
+
assert.equal(calls[0].body.base, 'main', 'payload must include base');
|
|
298
|
+
assert.equal(calls[0].body.body, 'PR body text', 'payload must include body');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// 9. GitHubGitForge.mergePullRequest — validates merge method
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
describe('GitHubGitForge.mergePullRequest', () => {
|
|
307
|
+
it('accepts valid merge methods: merge, squash, rebase', async () => {
|
|
308
|
+
for (const mergeMethod of ['merge', 'squash', 'rebase']) {
|
|
309
|
+
const calls = [];
|
|
310
|
+
const forge = new GitHubGitForge({
|
|
311
|
+
owner: 'my-org',
|
|
312
|
+
installationToken: 'ghs_test',
|
|
313
|
+
fetchImpl: async (url, options) => {
|
|
314
|
+
calls.push({ url, body: options?.body ? JSON.parse(options.body) : undefined });
|
|
315
|
+
return { ok: true, status: 200, json: async () => ({ merged: true, message: 'Pull Request successfully merged', sha: 'abc' }) };
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
const result = await forge.mergePullRequest({ repo: 'my-repo', pullNumber: 1, mergeMethod });
|
|
319
|
+
assert.equal(result.merged, true, `merge with method "${mergeMethod}" must succeed`);
|
|
320
|
+
assert.equal(calls[0].body.merge_method, mergeMethod, `payload must include merge_method="${mergeMethod}"`);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('rejects invalid merge method with a descriptive error', async () => {
|
|
325
|
+
const forge = makeForge();
|
|
326
|
+
await assert.rejects(
|
|
327
|
+
() => forge.mergePullRequest({ repo: 'my-repo', pullNumber: 1, mergeMethod: 'fast-forward' }),
|
|
328
|
+
/mergeMethod|merge_method|merge|squash|rebase/i,
|
|
329
|
+
'must throw on invalid mergeMethod'
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// 10. GitHubGitForge.listRefs — returns branches and tags
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
describe('GitHubGitForge.listRefs', () => {
|
|
339
|
+
it('returns normalized branches and tags from the repository', async () => {
|
|
340
|
+
const forge = new GitHubGitForge({
|
|
341
|
+
owner: 'my-org',
|
|
342
|
+
installationToken: 'ghs_test',
|
|
343
|
+
fetchImpl: makeMockFetch([
|
|
344
|
+
{
|
|
345
|
+
status: 200,
|
|
346
|
+
body: [
|
|
347
|
+
{ name: 'main', commit: { sha: 'abc123' }, protected: true },
|
|
348
|
+
{ name: 'develop', commit: { sha: 'def456' }, protected: false }
|
|
349
|
+
]
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
status: 200,
|
|
353
|
+
body: [
|
|
354
|
+
{ name: 'v1.0.0', commit: { sha: 'tag123' }, tarball_url: 'https://...' }
|
|
355
|
+
]
|
|
356
|
+
}
|
|
357
|
+
])
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const refs = await forge.listRefs({ repo: 'my-repo' });
|
|
361
|
+
|
|
362
|
+
assert.ok(refs, 'listRefs must return a value');
|
|
363
|
+
assert.ok(Array.isArray(refs.branches), 'refs.branches must be an array');
|
|
364
|
+
assert.ok(Array.isArray(refs.tags), 'refs.tags must be an array');
|
|
365
|
+
assert.equal(refs.branches.length, 2, 'must return 2 branches');
|
|
366
|
+
assert.equal(refs.branches[0].name, 'main', 'first branch name must be "main"');
|
|
367
|
+
assert.equal(refs.branches[0].sha, 'abc123', 'branch sha must be present');
|
|
368
|
+
assert.equal(refs.branches[0].protected, true, 'branch protected flag must be present');
|
|
369
|
+
assert.equal(refs.tags.length, 1, 'must return 1 tag');
|
|
370
|
+
assert.equal(refs.tags[0].name, 'v1.0.0', 'tag name must match');
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// 11. GitHubGitForge.syncDeployKeys — compares desired vs current keys
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
describe('GitHubGitForge.syncDeployKeys', () => {
|
|
379
|
+
it('adds missing keys and removes extra keys', async () => {
|
|
380
|
+
const calls = [];
|
|
381
|
+
const forge = new GitHubGitForge({
|
|
382
|
+
owner: 'my-org',
|
|
383
|
+
installationToken: 'ghs_test',
|
|
384
|
+
fetchImpl: async (url, options = {}) => {
|
|
385
|
+
calls.push({ url, method: options.method ?? 'GET', body: options.body ? JSON.parse(options.body) : undefined });
|
|
386
|
+
if ((options.method ?? 'GET') === 'GET') {
|
|
387
|
+
return {
|
|
388
|
+
ok: true, status: 200,
|
|
389
|
+
json: async () => [
|
|
390
|
+
{ id: 101, title: 'old-key', key: 'ssh-rsa OLDKEY', read_only: true }
|
|
391
|
+
]
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return { ok: true, status: 201, json: async () => ({ id: 102, title: 'new-key' }) };
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const result = await forge.syncDeployKeys({
|
|
399
|
+
repo: 'my-repo',
|
|
400
|
+
desiredKeys: [{ title: 'new-key', key: 'ssh-rsa NEWKEY', readOnly: true }]
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
assert.ok(result, 'syncDeployKeys must return a result');
|
|
404
|
+
const deleteCalls = calls.filter(c => c.method === 'DELETE');
|
|
405
|
+
const postCalls = calls.filter(c => c.method === 'POST');
|
|
406
|
+
assert.equal(deleteCalls.length, 1, 'must delete the extra key');
|
|
407
|
+
assert.ok(deleteCalls[0].url.includes('101'), 'must delete key with id 101');
|
|
408
|
+
assert.equal(postCalls.length, 1, 'must add the new key');
|
|
409
|
+
assert.equal(postCalls[0].body.title, 'new-key', 'must POST correct title');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('returns a summary with added and removed counts', async () => {
|
|
413
|
+
const forge = new GitHubGitForge({
|
|
414
|
+
owner: 'my-org',
|
|
415
|
+
installationToken: 'ghs_test',
|
|
416
|
+
fetchImpl: async (url, options = {}) => {
|
|
417
|
+
if ((options.method ?? 'GET') === 'GET') {
|
|
418
|
+
return { ok: true, status: 200, json: async () => [] };
|
|
419
|
+
}
|
|
420
|
+
return { ok: true, status: 201, json: async () => ({ id: 1, title: 'k1' }) };
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const result = await forge.syncDeployKeys({
|
|
425
|
+
repo: 'my-repo',
|
|
426
|
+
desiredKeys: [{ title: 'k1', key: 'ssh-rsa KEY1', readOnly: false }]
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
assert.ok('added' in result, 'result must have added count');
|
|
430
|
+
assert.ok('removed' in result, 'result must have removed count');
|
|
431
|
+
assert.equal(result.added, 1, 'must report 1 added key');
|
|
432
|
+
assert.equal(result.removed, 0, 'must report 0 removed keys');
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// 12. GitHubGitForge.syncBranchProtection — sends protection config
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
describe('GitHubGitForge.syncBranchProtection', () => {
|
|
441
|
+
it('sends PUT to the branch protection endpoint with correct config', async () => {
|
|
442
|
+
const calls = [];
|
|
443
|
+
const forge = new GitHubGitForge({
|
|
444
|
+
owner: 'my-org',
|
|
445
|
+
installationToken: 'ghs_test',
|
|
446
|
+
fetchImpl: async (url, options = {}) => {
|
|
447
|
+
calls.push({ url, method: options.method, body: options.body ? JSON.parse(options.body) : undefined });
|
|
448
|
+
return { ok: true, status: 200, json: async () => ({ url, required_status_checks: null }) };
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
await forge.syncBranchProtection({
|
|
453
|
+
repo: 'my-repo',
|
|
454
|
+
branch: 'main',
|
|
455
|
+
requiredReviews: 2,
|
|
456
|
+
requiredStatusChecks: ['ci/build', 'ci/test'],
|
|
457
|
+
dismissStaleReviews: true
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
assert.equal(calls.length, 1, 'exactly one HTTP call must be made');
|
|
461
|
+
assert.equal(calls[0].method, 'PUT', 'must use PUT method');
|
|
462
|
+
assert.ok(calls[0].url.includes('/repos/my-org/my-repo/branches/main/protection'), `URL must target protection endpoint, got: ${calls[0].url}`);
|
|
463
|
+
const body = calls[0].body;
|
|
464
|
+
assert.ok(body, 'request body must be present');
|
|
465
|
+
assert.ok(body.required_pull_request_reviews || body.required_status_checks !== undefined, 'body must include protection config');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('sends required_approving_review_count matching requiredReviews', async () => {
|
|
469
|
+
const calls = [];
|
|
470
|
+
const forge = new GitHubGitForge({
|
|
471
|
+
owner: 'my-org',
|
|
472
|
+
installationToken: 'ghs_test',
|
|
473
|
+
fetchImpl: async (url, options = {}) => {
|
|
474
|
+
calls.push({ body: options.body ? JSON.parse(options.body) : undefined });
|
|
475
|
+
return { ok: true, status: 200, json: async () => ({}) };
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
await forge.syncBranchProtection({ repo: 'my-repo', branch: 'main', requiredReviews: 3 });
|
|
480
|
+
|
|
481
|
+
const body = calls[0].body;
|
|
482
|
+
assert.equal(
|
|
483
|
+
body?.required_pull_request_reviews?.required_approving_review_count,
|
|
484
|
+
3,
|
|
485
|
+
'must set required_approving_review_count to 3'
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// 13. GitHubGitForge implements the gitForge interface contract
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
describe('GitHubGitForge — interface contract', () => {
|
|
495
|
+
it('exposes all required git forge interface methods', () => {
|
|
496
|
+
const forge = makeForge();
|
|
497
|
+
const requiredMethods = [
|
|
498
|
+
'listRepositories',
|
|
499
|
+
'getPullRequest',
|
|
500
|
+
'createPullRequest',
|
|
501
|
+
'mergePullRequest',
|
|
502
|
+
'listRefs',
|
|
503
|
+
'syncDeployKeys',
|
|
504
|
+
'syncBranchProtection'
|
|
505
|
+
];
|
|
506
|
+
for (const method of requiredMethods) {
|
|
507
|
+
assert.equal(typeof forge[method], 'function', `GitHubGitForge must expose method: ${method}`);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('has a role property identifying it as github-git-forge', () => {
|
|
512
|
+
const forge = makeForge();
|
|
513
|
+
assert.equal(forge.role, 'github-git-forge', 'role must be "github-git-forge"');
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// 14. createGitHubProvider — returns a valid ExternalProviderAdapter
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
describe('createGitHubProvider', () => {
|
|
522
|
+
it('returns an object with required ExternalProviderAdapter fields', () => {
|
|
523
|
+
const provider = createGitHubProvider({
|
|
524
|
+
appId: '12345',
|
|
525
|
+
privateKey: 'test-key',
|
|
526
|
+
installationId: '42'
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
assert.ok(provider, 'createGitHubProvider must return a value');
|
|
530
|
+
assert.equal(provider.type, 'github', 'provider.type must be "github"');
|
|
531
|
+
assert.equal(typeof provider.createForge, 'function', 'provider must expose createForge function');
|
|
532
|
+
assert.ok(provider.config, 'provider must expose config');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('throws when required fields are missing', () => {
|
|
536
|
+
assert.throws(
|
|
537
|
+
() => createGitHubProvider({ privateKey: 'key' }),
|
|
538
|
+
/appId/i,
|
|
539
|
+
'must throw when appId is missing'
|
|
540
|
+
);
|
|
541
|
+
assert.throws(
|
|
542
|
+
() => createGitHubProvider({ appId: '1' }),
|
|
543
|
+
/privateKey/i,
|
|
544
|
+
'must throw when privateKey is missing'
|
|
545
|
+
);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// 15. GITHUB_GIT_FORGE_BOUNDARY — boundary object export
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
describe('GITHUB_GIT_FORGE_BOUNDARY', () => {
|
|
554
|
+
it('is exported with correct role', () => {
|
|
555
|
+
assert.ok(GITHUB_GIT_FORGE_BOUNDARY, 'GITHUB_GIT_FORGE_BOUNDARY must be exported');
|
|
556
|
+
assert.equal(GITHUB_GIT_FORGE_BOUNDARY.role, 'github-git-forge', 'role must be "github-git-forge"');
|
|
557
|
+
assert.ok(Array.isArray(GITHUB_GIT_FORGE_BOUNDARY.owns), 'BOUNDARY must declare owned concerns');
|
|
558
|
+
assert.ok(GITHUB_GIT_FORGE_BOUNDARY.scope, 'BOUNDARY must declare scope');
|
|
559
|
+
});
|
|
560
|
+
});
|