Compare commits
21 Commits
71c7b85026
...
fix/git-wr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67c1ad155e | ||
|
|
9547dc8b97 | ||
|
|
43b3759ce2 | ||
|
|
4a7bebb1cc | ||
|
|
6422c65961 | ||
|
|
a70159d350 | ||
|
|
ae076e194a | ||
|
|
7864e0b3b3 | ||
|
|
5caf85d072 | ||
|
|
1471089c42 | ||
|
|
b7209e1e92 | ||
|
|
e2d49aface | ||
| 755df9079e | |||
| ac5650d9f9 | |||
| bd83f86740 | |||
|
|
0af3e218a1 | ||
|
|
b01c9b3bb0 | ||
| b67f2c9f08 | |||
|
|
37675ae3f2 | ||
|
|
a4a6769a6d | ||
|
|
21650fb194 |
@@ -46,18 +46,28 @@ steps:
|
|||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://mosaic:mosaic@postgres:5432/mosaic
|
# Avoid the namespace-level Woodpecker DB service named "postgres".
|
||||||
|
# The Kubernetes backend exposes service containers by step name.
|
||||||
|
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# Install postgresql-client for pg_isready
|
# Install postgresql-client for pg_isready
|
||||||
- apk add --no-cache postgresql-client
|
- apk add --no-cache postgresql-client
|
||||||
# Wait up to 30s for postgres to be ready
|
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||||
- |
|
- |
|
||||||
for i in $(seq 1 30); do
|
ready=0
|
||||||
pg_isready -h postgres -p 5432 -U mosaic && break
|
for i in $(seq 1 60); do
|
||||||
echo "Waiting for postgres ($i/30)..."
|
if pg_isready -h ci-postgres -p 5432 -U mosaic; then
|
||||||
|
ready=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Waiting for ci-postgres ($i/60)..."
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
if [ "$ready" -ne 1 ]; then
|
||||||
|
echo "ci-postgres did not become ready" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
# Run migrations (DATABASE_URL is set in environment above)
|
# Run migrations (DATABASE_URL is set in environment above)
|
||||||
- pnpm --filter @mosaicstack/db run db:migrate
|
- pnpm --filter @mosaicstack/db run db:migrate
|
||||||
# Run all tests
|
# Run all tests
|
||||||
@@ -66,7 +76,7 @@ steps:
|
|||||||
- typecheck
|
- typecheck
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
ci-postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: mosaic
|
POSTGRES_USER: mosaic
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
"uuid": "^11.0.0",
|
"uuid": "^11.0.0",
|
||||||
|
"undici": "^7.24.6",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { Global, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
import {
|
||||||
import { createDb, createPgliteDb, type Db, type DbHandle } from '@mosaicstack/db';
|
Global,
|
||||||
|
Inject,
|
||||||
|
Logger,
|
||||||
|
Module,
|
||||||
|
type OnApplicationShutdown,
|
||||||
|
type OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
createDb,
|
||||||
|
createPgliteDb,
|
||||||
|
runPgliteMigrations,
|
||||||
|
type Db,
|
||||||
|
type DbHandle,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
import { createStorageAdapter, type StorageAdapter } from '@mosaicstack/storage';
|
import { createStorageAdapter, type StorageAdapter } from '@mosaicstack/storage';
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
@@ -39,12 +52,37 @@ export const STORAGE_ADAPTER = 'STORAGE_ADAPTER';
|
|||||||
],
|
],
|
||||||
exports: [DB, STORAGE_ADAPTER],
|
exports: [DB, STORAGE_ADAPTER],
|
||||||
})
|
})
|
||||||
export class DatabaseModule implements OnApplicationShutdown {
|
export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||||
|
private readonly logger = new Logger(DatabaseModule.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DB_HANDLE) private readonly handle: DbHandle,
|
@Inject(DB_HANDLE) private readonly handle: DbHandle,
|
||||||
@Inject(STORAGE_ADAPTER) private readonly storageAdapter: StorageAdapter,
|
@Inject(STORAGE_ADAPTER) private readonly storageAdapter: StorageAdapter,
|
||||||
|
@Inject(MOSAIC_CONFIG) private readonly config: MosaicConfig,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// Migrations must complete before any module that injects DB starts serving
|
||||||
|
// requests. NestJS awaits onModuleInit before app.listen(), and modules that
|
||||||
|
// inject DB are initialized after this one — so all DB-dependent code sees a
|
||||||
|
// populated schema before the first HTTP request lands.
|
||||||
|
//
|
||||||
|
// Local (PGlite) tier: we run gateway-DB migrations explicitly here. The
|
||||||
|
// storage adapter writes to a separate PGlite directory and only manages its
|
||||||
|
// own KV tables, so we still call its migrate() afterwards.
|
||||||
|
//
|
||||||
|
// Postgres tier: PostgresAdapter.migrate() already calls runMigrations() on
|
||||||
|
// the same DATABASE_URL, so a single call covers both the gateway DB and
|
||||||
|
// the storage tables. We deliberately do NOT call runMigrations() here to
|
||||||
|
// avoid opening a second short-lived connection and doubling startup cost.
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
if (this.config.tier === 'local') {
|
||||||
|
this.logger.log('Applying PGlite schema migrations...');
|
||||||
|
await runPgliteMigrations(this.handle);
|
||||||
|
}
|
||||||
|
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
|
||||||
|
await this.storageAdapter.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
await Promise.all([this.handle.close(), this.storageAdapter.close()]);
|
await Promise.all([this.handle.close(), this.storageAdapter.close()]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for FederationClientService (FED-M3-08).
|
||||||
|
*
|
||||||
|
* HTTP mocking strategy:
|
||||||
|
* undici MockAgent is used to intercept outbound HTTP requests. The service
|
||||||
|
* uses `undici.fetch` with a `dispatcher` option, so MockAgent is set as the
|
||||||
|
* global dispatcher and all requests flow through it.
|
||||||
|
*
|
||||||
|
* Because the service builds one `undici.Agent` per peer and passes it as
|
||||||
|
* the dispatcher on every fetch call, we cannot intercept at the Agent level
|
||||||
|
* in unit tests without significant refactoring. Instead, we set the global
|
||||||
|
* dispatcher to a MockAgent and override the service's `doRequest` indirection
|
||||||
|
* by spying on the internal fetch call.
|
||||||
|
*
|
||||||
|
* For the cert/key wiring, we use the real `sealClientKey` function from
|
||||||
|
* peer-key.util.ts with a test secret — no stubs.
|
||||||
|
*
|
||||||
|
* Sealed-key setup:
|
||||||
|
* Each test (or beforeAll) calls `sealClientKey(TEST_PRIVATE_KEY_PEM)` with
|
||||||
|
* BETTER_AUTH_SECRET set to a deterministic test value so that
|
||||||
|
* `unsealClientKey` in the service recovers the original PEM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
|
||||||
|
import type { Dispatcher } from 'undici';
|
||||||
|
import { writeFileSync, unlinkSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Db } from '@mosaicstack/db';
|
||||||
|
import { FederationClientService, FederationClientError } from '../federation-client.service.js';
|
||||||
|
import { sealClientKey } from '../../peer-key.util.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TEST_SECRET = 'test-secret-for-federation-client-spec-only';
|
||||||
|
const PEER_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||||
|
const ENDPOINT = 'https://peer.example.com';
|
||||||
|
|
||||||
|
// Minimal valid RSA/EC private key PEM — does NOT need to be a real key for
|
||||||
|
// unit tests because we only verify it round-trips through seal/unseal, not
|
||||||
|
// that it actually negotiates TLS (MockAgent handles that).
|
||||||
|
const TEST_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDummyKeyForTests
|
||||||
|
-----END PRIVATE KEY-----`;
|
||||||
|
|
||||||
|
// Minimal self-signed cert PEM (dummy — only used for mTLS Agent construction)
|
||||||
|
const TEST_CERT_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBdummyCertForFederationClientTests==
|
||||||
|
-----END CERTIFICATE-----`;
|
||||||
|
|
||||||
|
const TEST_CERT_SERIAL = 'ABCDEF1234567890';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sealed key (computed once in beforeAll)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let SEALED_KEY: string;
|
||||||
|
|
||||||
|
// Path to a stub Step-CA root cert file written in beforeAll. The cert is never
|
||||||
|
// actually used to negotiate TLS in unit tests (MockAgent + spy on resolveEntry
|
||||||
|
// short-circuit the network), but loadStepCaRoot() requires the file to exist.
|
||||||
|
const STUB_CA_PEM_PATH = join(tmpdir(), 'federation-client-spec-ca.pem');
|
||||||
|
const STUB_CA_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBdummyCAforFederationClientSpecOnly==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Peer row factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makePeerRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: PEER_ID,
|
||||||
|
commonName: 'peer-example-com',
|
||||||
|
displayName: 'Test Peer',
|
||||||
|
certPem: TEST_CERT_PEM,
|
||||||
|
certSerial: TEST_CERT_SERIAL,
|
||||||
|
certNotAfter: new Date('2030-01-01T00:00:00Z'),
|
||||||
|
clientKeyPem: SEALED_KEY,
|
||||||
|
state: 'active' as const,
|
||||||
|
endpointUrl: ENDPOINT,
|
||||||
|
lastSeenAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
revokedAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock DB builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeDb(selectRows: unknown[] = [makePeerRow()]): Db {
|
||||||
|
const limitSelect = vi.fn().mockResolvedValue(selectRows);
|
||||||
|
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||||
|
const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
|
||||||
|
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: selectMock,
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
} as unknown as Db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers for MockAgent HTTP interception
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a MockAgent + MockPool for the peer endpoint, set it as the global
|
||||||
|
* dispatcher, and return both for per-test configuration.
|
||||||
|
*/
|
||||||
|
function makeMockAgent() {
|
||||||
|
const mockAgent = new MockAgent({ connections: 1 });
|
||||||
|
mockAgent.disableNetConnect();
|
||||||
|
setGlobalDispatcher(mockAgent);
|
||||||
|
const pool = mockAgent.get(ENDPOINT);
|
||||||
|
return { mockAgent, pool };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a FederationClientService with a mock DB and a spy on the internal
|
||||||
|
* fetch so we can intercept at the HTTP layer via MockAgent.
|
||||||
|
*
|
||||||
|
* The service calls `fetch(url, { dispatcher: agent })` where `agent` is the
|
||||||
|
* mTLS undici.Agent built from the peer's cert+key. To make MockAgent work,
|
||||||
|
* we need the fetch dispatcher to be the MockAgent, not the per-peer Agent.
|
||||||
|
*
|
||||||
|
* Strategy: we replace the private `resolveEntry` result's `agent` field with
|
||||||
|
* the MockAgent's pool, so fetch uses our interceptor. We do this by spying
|
||||||
|
* on `resolveEntry` and returning a controlled entry.
|
||||||
|
*/
|
||||||
|
function makeService(db: Db, mockPool: Dispatcher): FederationClientService {
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
|
||||||
|
// Override resolveEntry to inject MockAgent pool as the dispatcher
|
||||||
|
vi.spyOn(
|
||||||
|
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
|
||||||
|
'resolveEntry',
|
||||||
|
).mockImplementation(async (_peerId: string) => {
|
||||||
|
// Still call DB (via the real logic) to exercise peer validation,
|
||||||
|
// but return mock pool as the agent.
|
||||||
|
// For simplicity in unit tests, directly return a controlled entry.
|
||||||
|
return {
|
||||||
|
agent: mockPool,
|
||||||
|
endpointUrl: ENDPOINT,
|
||||||
|
certPem: TEST_CERT_PEM,
|
||||||
|
certSerial: TEST_CERT_SERIAL,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return svc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let originalDispatcher: Dispatcher;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Seal the test key once — requires BETTER_AUTH_SECRET
|
||||||
|
const saved = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||||
|
try {
|
||||||
|
SEALED_KEY = sealClientKey(TEST_PRIVATE_KEY_PEM);
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) {
|
||||||
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
} else {
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFileSync(STUB_CA_PEM_PATH, STUB_CA_PEM, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
try {
|
||||||
|
unlinkSync(STUB_CA_PEM_PATH);
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalDispatcher = getGlobalDispatcher();
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||||
|
process.env['STEP_CA_ROOT_CERT_PATH'] = STUB_CA_PEM_PATH;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setGlobalDispatcher(originalDispatcher);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
delete process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Successful list response body */
|
||||||
|
const LIST_BODY = {
|
||||||
|
items: [{ id: '1', title: 'Task One' }],
|
||||||
|
nextCursor: undefined,
|
||||||
|
_partial: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Successful get response body */
|
||||||
|
const GET_BODY = {
|
||||||
|
item: { id: '1', title: 'Task One' },
|
||||||
|
_partial: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Successful capabilities response body */
|
||||||
|
const CAP_BODY = {
|
||||||
|
resources: ['tasks'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
supported_verbs: ['list', 'get', 'capabilities'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('FederationClientService', () => {
|
||||||
|
// ─── Successful verb calls ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('list()', () => {
|
||||||
|
it('returns parsed typed response on success', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({
|
||||||
|
path: '/api/federation/v1/list/tasks',
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.reply(200, LIST_BODY, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const result = await svc.list(PEER_ID, 'tasks', {});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({ id: '1', title: 'Task One' });
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get()', () => {
|
||||||
|
it('returns parsed typed response on success', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({
|
||||||
|
path: '/api/federation/v1/get/tasks/1',
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.reply(200, GET_BODY, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const result = await svc.get(PEER_ID, 'tasks', '1', {});
|
||||||
|
|
||||||
|
expect(result.item).toMatchObject({ id: '1', title: 'Task One' });
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('capabilities()', () => {
|
||||||
|
it('returns parsed capabilities response on success', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({
|
||||||
|
path: '/api/federation/v1/capabilities',
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const result = await svc.capabilities(PEER_ID);
|
||||||
|
|
||||||
|
expect(result.resources).toContain('tasks');
|
||||||
|
expect(result.max_rows_per_query).toBe(100);
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── HTTP error surfaces ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('non-2xx responses', () => {
|
||||||
|
it('surfaces 403 as FederationClientError({ status: 403, code: "FORBIDDEN" })', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool.intercept({ path: '/api/federation/v1/list/tasks', method: 'POST' }).reply(
|
||||||
|
403,
|
||||||
|
{ error: { code: 'forbidden', message: 'Access denied' } },
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.list(PEER_ID, 'tasks', {})).rejects.toMatchObject({
|
||||||
|
status: 403,
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces 404 as FederationClientError({ status: 404, code: "HTTP_404" })', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool.intercept({ path: '/api/federation/v1/get/tasks/999', method: 'POST' }).reply(
|
||||||
|
404,
|
||||||
|
{ error: { code: 'not_found', message: 'Not found' } },
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.get(PEER_ID, 'tasks', '999', {})).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
code: 'HTTP_404',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Network error ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('network errors', () => {
|
||||||
|
it('surfaces network error as FederationClientError({ code: "NETWORK" })', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||||
|
.replyWithError(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'NETWORK',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Invalid response body ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('invalid response body', () => {
|
||||||
|
it('surfaces as FederationClientError({ code: "INVALID_RESPONSE" }) when body shape is wrong', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
// capabilities returns wrong shape (missing required fields)
|
||||||
|
pool
|
||||||
|
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||||
|
.reply(200, { totally: 'wrong' }, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'INVALID_RESPONSE',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Peer DB validation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('peer validation (without resolveEntry spy)', () => {
|
||||||
|
/**
|
||||||
|
* These tests exercise the real `resolveEntry` path — no spy on resolveEntry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
it('throws PEER_NOT_FOUND when peer is not in DB', async () => {
|
||||||
|
// DB returns empty array (peer not found)
|
||||||
|
const db = makeDb([]);
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'PEER_NOT_FOUND',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws PEER_INACTIVE when peer state is not "active"', async () => {
|
||||||
|
const db = makeDb([makePeerRow({ state: 'suspended' })]);
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'PEER_INACTIVE',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cache behaviour ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('cache behaviour', () => {
|
||||||
|
it('hits cache on second call — only one DB lookup happens', async () => {
|
||||||
|
// Verify cache by calling the private resolveEntry directly twice and
|
||||||
|
// asserting the DB was queried only once. This avoids the HTTP layer,
|
||||||
|
// which would require either a real network or per-peer Agent rewiring
|
||||||
|
// that the cache invariant doesn't depend on.
|
||||||
|
const db = makeDb();
|
||||||
|
const selectSpy = vi.spyOn(db, 'select');
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> }
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
const first = await resolveEntry(PEER_ID);
|
||||||
|
const second = await resolveEntry(PEER_ID);
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(selectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent resolveEntry calls — only one DB lookup', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const selectSpy = vi.spyOn(db, 'select');
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as {
|
||||||
|
resolveEntry: (peerId: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([resolveEntry(PEER_ID), resolveEntry(PEER_ID)]);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(selectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushPeer destroys the evicted Agent so old TLS connections close', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as {
|
||||||
|
resolveEntry: (peerId: string) => Promise<{ agent: { destroy: () => Promise<void> } }>;
|
||||||
|
}
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
const entry = await resolveEntry(PEER_ID);
|
||||||
|
const destroySpy = vi.spyOn(entry.agent, 'destroy').mockResolvedValue();
|
||||||
|
|
||||||
|
svc.flushPeer(PEER_ID);
|
||||||
|
expect(destroySpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushPeer() invalidates cache — next call re-reads DB', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||||
|
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } })
|
||||||
|
.times(2);
|
||||||
|
|
||||||
|
// First call — populates cache (via mock resolveEntry)
|
||||||
|
await svc.capabilities(PEER_ID);
|
||||||
|
|
||||||
|
// Flush the cache
|
||||||
|
svc.flushPeer(PEER_ID);
|
||||||
|
|
||||||
|
// The spy on resolveEntry is still active — check it's called again after flush
|
||||||
|
const resolveEntrySpy = vi.spyOn(
|
||||||
|
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
|
||||||
|
'resolveEntry',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second call after flush — should call resolveEntry again
|
||||||
|
await svc.capabilities(PEER_ID);
|
||||||
|
|
||||||
|
// resolveEntry should have been called once after we started spying (post-flush)
|
||||||
|
expect(resolveEntrySpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── loadStepCaRoot env-var guard ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('loadStepCaRoot() env-var guard', () => {
|
||||||
|
it('throws PEER_MISCONFIGURED when STEP_CA_ROOT_CERT_PATH is not set', async () => {
|
||||||
|
delete process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
const db = makeDb();
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as {
|
||||||
|
resolveEntry: (peerId: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
await expect(resolveEntry(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── FederationClientError class ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('FederationClientError', () => {
|
||||||
|
it('is instanceof Error and FederationClientError', () => {
|
||||||
|
const err = new FederationClientError({
|
||||||
|
code: 'PEER_NOT_FOUND',
|
||||||
|
message: 'test',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err).toBeInstanceOf(FederationClientError);
|
||||||
|
expect(err.name).toBe('FederationClientError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries status, code, and peerId', () => {
|
||||||
|
const err = new FederationClientError({
|
||||||
|
status: 403,
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'forbidden',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
expect(err.status).toBe(403);
|
||||||
|
expect(err.code).toBe('FORBIDDEN');
|
||||||
|
expect(err.peerId).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
500
apps/gateway/src/federation/client/federation-client.service.ts
Normal file
500
apps/gateway/src/federation/client/federation-client.service.ts
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* FederationClientService — outbound mTLS client for federation requests (FED-M3-08).
|
||||||
|
*
|
||||||
|
* Dials peer gateways over mTLS using the cert+sealed-key stored in `federation_peers`,
|
||||||
|
* invokes federation verbs (list / get / capabilities), and surfaces all failure modes
|
||||||
|
* as typed `FederationClientError` instances.
|
||||||
|
*
|
||||||
|
* ## Error code taxonomy
|
||||||
|
*
|
||||||
|
* | Code | When |
|
||||||
|
* | ------------------ | ------------------------------------------------------------- |
|
||||||
|
* | PEER_NOT_FOUND | No row in federation_peers for the given peerId |
|
||||||
|
* | PEER_INACTIVE | Peer row exists but state !== 'active' |
|
||||||
|
* | PEER_MISCONFIGURED | Peer row is active but missing endpointUrl or clientKeyPem |
|
||||||
|
* | NETWORK | undici threw a connection / TLS / timeout error |
|
||||||
|
* | HTTP_{status} | Peer returned a non-2xx response (e.g. HTTP_403, HTTP_404) |
|
||||||
|
* | FORBIDDEN | Peer returned 403 (convenience alias alongside HTTP_403) |
|
||||||
|
* | INVALID_RESPONSE | Response body failed Zod schema validation |
|
||||||
|
*
|
||||||
|
* ## Cache strategy
|
||||||
|
*
|
||||||
|
* Per-peer `undici.Agent` instances are cached in a `Map<peerId, AgentCacheEntry>` for
|
||||||
|
* the lifetime of the service instance. The cache is keyed on peerId (UUID).
|
||||||
|
*
|
||||||
|
* Cache invalidation:
|
||||||
|
* - `flushPeer(peerId)` — removes the entry immediately. M5/M6 MUST call this on
|
||||||
|
* cert rotation or peer revocation events so the next request re-reads the DB and
|
||||||
|
* builds a fresh TLS Agent with the new cert material.
|
||||||
|
* - On cache miss: re-reads the DB, checks state === 'active', rebuilds Agent.
|
||||||
|
*
|
||||||
|
* Cache does NOT auto-expire. The service is expected to be a singleton scoped to the
|
||||||
|
* NestJS application lifecycle; flushing on revocation/rotation is the only invalidation
|
||||||
|
* path by design (avoids redundant DB round-trips on the hot path).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Agent, fetch as undiciFetch } from 'undici';
|
||||||
|
import type { Dispatcher } from 'undici';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { type Db, eq, federationPeers } from '@mosaicstack/db';
|
||||||
|
import {
|
||||||
|
FederationListResponseSchema,
|
||||||
|
FederationGetResponseSchema,
|
||||||
|
FederationCapabilitiesResponseSchema,
|
||||||
|
FederationErrorEnvelopeSchema,
|
||||||
|
type FederationListResponse,
|
||||||
|
type FederationGetResponse,
|
||||||
|
type FederationCapabilitiesResponse,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { DB } from '../../database/database.module.js';
|
||||||
|
import { unsealClientKey } from '../peer-key.util.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error taxonomy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side error code set. Distinct from the server-side `FederationErrorCode`
|
||||||
|
* (which lives in `@mosaicstack/types`) because the client has additional failure
|
||||||
|
* modes (PEER_NOT_FOUND, PEER_INACTIVE, PEER_MISCONFIGURED, NETWORK) that the
|
||||||
|
* server never emits.
|
||||||
|
*/
|
||||||
|
export type FederationClientErrorCode =
|
||||||
|
| 'PEER_NOT_FOUND'
|
||||||
|
| 'PEER_INACTIVE'
|
||||||
|
| 'PEER_MISCONFIGURED'
|
||||||
|
| 'NETWORK'
|
||||||
|
| 'FORBIDDEN'
|
||||||
|
| 'INVALID_RESPONSE'
|
||||||
|
| `HTTP_${number}`;
|
||||||
|
|
||||||
|
export interface FederationClientErrorOptions {
|
||||||
|
status?: number;
|
||||||
|
code: FederationClientErrorCode;
|
||||||
|
message: string;
|
||||||
|
peerId: string;
|
||||||
|
cause?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by FederationClientService on every failure path.
|
||||||
|
* Callers can dispatch on `error.code` for programmatic handling.
|
||||||
|
*/
|
||||||
|
export class FederationClientError extends Error {
|
||||||
|
readonly status?: number;
|
||||||
|
readonly code: FederationClientErrorCode;
|
||||||
|
readonly peerId: string;
|
||||||
|
readonly cause?: unknown;
|
||||||
|
|
||||||
|
constructor(opts: FederationClientErrorOptions) {
|
||||||
|
super(opts.message);
|
||||||
|
this.name = 'FederationClientError';
|
||||||
|
this.status = opts.status;
|
||||||
|
this.code = opts.code;
|
||||||
|
this.peerId = opts.peerId;
|
||||||
|
this.cause = opts.cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal cache types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AgentCacheEntry {
|
||||||
|
agent: Agent;
|
||||||
|
endpointUrl: string;
|
||||||
|
certPem: string;
|
||||||
|
certSerial: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FederationClientService {
|
||||||
|
private readonly logger = new Logger(FederationClientService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-peer undici Agent cache.
|
||||||
|
* Key = peerId (UUID string).
|
||||||
|
*
|
||||||
|
* Values are either a resolved `AgentCacheEntry` or an in-flight
|
||||||
|
* `Promise<AgentCacheEntry>` (promise-cache pattern). Storing the promise
|
||||||
|
* prevents duplicate DB lookups and duplicate key-unseal operations when two
|
||||||
|
* requests for the same peer arrive before the first build completes.
|
||||||
|
*
|
||||||
|
* Flush via `flushPeer(peerId)` on cert rotation / peer revocation (M5/M6).
|
||||||
|
*/
|
||||||
|
private readonly cache = new Map<string, AgentCacheEntry | Promise<AgentCacheEntry>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step-CA root cert PEM, loaded once from `STEP_CA_ROOT_CERT_PATH`.
|
||||||
|
* Used as the trust anchor for peer server certificates so federation TLS is
|
||||||
|
* pinned to our PKI, not the public trust store. Lazily loaded on first use
|
||||||
|
* so unit tests that don't exercise the agent path can run without the env var.
|
||||||
|
*/
|
||||||
|
private cachedCaPem: string | null = null;
|
||||||
|
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Public verb API
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the `list` verb on a remote peer.
|
||||||
|
*
|
||||||
|
* @param peerId UUID of the peer row in `federation_peers`.
|
||||||
|
* @param resource Resource path, e.g. "tasks".
|
||||||
|
* @param request Free-form body sent as JSON in the POST body.
|
||||||
|
* @returns Parsed `FederationListResponse<T>`.
|
||||||
|
*/
|
||||||
|
async list<T>(
|
||||||
|
peerId: string,
|
||||||
|
resource: string,
|
||||||
|
request: Record<string, unknown>,
|
||||||
|
): Promise<FederationListResponse<T>> {
|
||||||
|
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||||
|
const url = `${endpointUrl}/api/federation/v1/list/${encodeURIComponent(resource)}`;
|
||||||
|
const body = await this.doPost(peerId, url, agent, request);
|
||||||
|
return this.parseWith<FederationListResponse<T>>(
|
||||||
|
peerId,
|
||||||
|
body,
|
||||||
|
FederationListResponseSchema(z.unknown()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the `get` verb on a remote peer.
|
||||||
|
*
|
||||||
|
* @param peerId UUID of the peer row in `federation_peers`.
|
||||||
|
* @param resource Resource path, e.g. "tasks".
|
||||||
|
* @param id Resource identifier.
|
||||||
|
* @param request Free-form body sent as JSON in the POST body.
|
||||||
|
* @returns Parsed `FederationGetResponse<T>`.
|
||||||
|
*/
|
||||||
|
async get<T>(
|
||||||
|
peerId: string,
|
||||||
|
resource: string,
|
||||||
|
id: string,
|
||||||
|
request: Record<string, unknown>,
|
||||||
|
): Promise<FederationGetResponse<T>> {
|
||||||
|
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||||
|
const url = `${endpointUrl}/api/federation/v1/get/${encodeURIComponent(resource)}/${encodeURIComponent(id)}`;
|
||||||
|
const body = await this.doPost(peerId, url, agent, request);
|
||||||
|
return this.parseWith<FederationGetResponse<T>>(
|
||||||
|
peerId,
|
||||||
|
body,
|
||||||
|
FederationGetResponseSchema(z.unknown()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the `capabilities` verb on a remote peer.
|
||||||
|
*
|
||||||
|
* @param peerId UUID of the peer row in `federation_peers`.
|
||||||
|
* @returns Parsed `FederationCapabilitiesResponse`.
|
||||||
|
*/
|
||||||
|
async capabilities(peerId: string): Promise<FederationCapabilitiesResponse> {
|
||||||
|
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||||
|
const url = `${endpointUrl}/api/federation/v1/capabilities`;
|
||||||
|
const body = await this.doGet(peerId, url, agent);
|
||||||
|
return this.parseWith<FederationCapabilitiesResponse>(
|
||||||
|
peerId,
|
||||||
|
body,
|
||||||
|
FederationCapabilitiesResponseSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Cache management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush the cached Agent for a specific peer.
|
||||||
|
*
|
||||||
|
* M5/M6 MUST call this on:
|
||||||
|
* - cert rotation events (so new cert material is picked up)
|
||||||
|
* - peer revocation events (so future requests fail at PEER_INACTIVE)
|
||||||
|
*
|
||||||
|
* After flushing, the next call to `list`, `get`, or `capabilities` for
|
||||||
|
* this peer will re-read the DB and rebuild the Agent.
|
||||||
|
*/
|
||||||
|
flushPeer(peerId: string): void {
|
||||||
|
const entry = this.cache.get(peerId);
|
||||||
|
if (entry === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cache.delete(peerId);
|
||||||
|
if (!(entry instanceof Promise)) {
|
||||||
|
// best-effort destroy; promise-cached entries skip destroy because
|
||||||
|
// the in-flight build owns its own Agent which will be GC'd when the
|
||||||
|
// owning request handles the rejection from the cache miss
|
||||||
|
entry.agent.destroy().catch(() => {
|
||||||
|
// intentionally ignored — destroy errors are not actionable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.log(`Cache flushed for peer ${peerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and cache the Step-CA root cert PEM from `STEP_CA_ROOT_CERT_PATH`.
|
||||||
|
* Throws `FederationClientError` if the env var is unset or the file cannot
|
||||||
|
* be read — mTLS to a peer without a pinned trust anchor would silently
|
||||||
|
* fall back to the public trust store.
|
||||||
|
*/
|
||||||
|
private loadStepCaRoot(): string {
|
||||||
|
if (this.cachedCaPem !== null) {
|
||||||
|
return this.cachedCaPem;
|
||||||
|
}
|
||||||
|
const path = process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
if (!path) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: 'STEP_CA_ROOT_CERT_PATH is not set; refusing to dial peer without pinned CA trust',
|
||||||
|
peerId: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pem = readFileSync(path, 'utf8');
|
||||||
|
this.cachedCaPem = pem;
|
||||||
|
return pem;
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: `Failed to read STEP_CA_ROOT_CERT_PATH (${path})`,
|
||||||
|
peerId: '',
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the cache entry for a peer, reading DB on miss.
|
||||||
|
*
|
||||||
|
* Uses a promise-cache pattern: concurrent callers for the same uncached
|
||||||
|
* `peerId` all `await` the same in-flight `Promise<AgentCacheEntry>` so
|
||||||
|
* only one DB lookup and one key-unseal ever runs per peer per cache miss.
|
||||||
|
* The promise is replaced with the concrete entry on success, or deleted on
|
||||||
|
* rejection so a transient error does not poison the cache permanently.
|
||||||
|
*
|
||||||
|
* Throws `FederationClientError` with appropriate code if the peer is not
|
||||||
|
* found, is inactive, or is missing required fields.
|
||||||
|
*/
|
||||||
|
private async resolveEntry(peerId: string): Promise<AgentCacheEntry> {
|
||||||
|
const cached = this.cache.get(peerId);
|
||||||
|
if (cached) {
|
||||||
|
return cached; // Promise or concrete entry — both are awaitable
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = this.buildEntry(peerId).then(
|
||||||
|
(entry) => {
|
||||||
|
this.cache.set(peerId, entry); // replace promise with concrete value
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
this.cache.delete(peerId); // don't poison the cache with a rejected promise
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.cache.set(peerId, inflight);
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `AgentCacheEntry` for a peer by reading the DB, validating the
|
||||||
|
* peer's state, unsealing the private key, and constructing the mTLS Agent.
|
||||||
|
*
|
||||||
|
* Throws `FederationClientError` with appropriate code if the peer is not
|
||||||
|
* found, is inactive, or is missing required fields.
|
||||||
|
*/
|
||||||
|
private async buildEntry(peerId: string): Promise<AgentCacheEntry> {
|
||||||
|
// DB lookup
|
||||||
|
const [peer] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(federationPeers)
|
||||||
|
.where(eq(federationPeers.id, peerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_NOT_FOUND',
|
||||||
|
message: `Federation peer ${peerId} not found`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peer.state !== 'active') {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_INACTIVE',
|
||||||
|
message: `Federation peer ${peerId} is not active (state: ${peer.state})`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!peer.endpointUrl || !peer.clientKeyPem) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: `Federation peer ${peerId} is missing endpointUrl or clientKeyPem`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unseal the private key
|
||||||
|
let privateKeyPem: string;
|
||||||
|
try {
|
||||||
|
privateKeyPem = unsealClientKey(peer.clientKeyPem);
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: `Failed to unseal client key for peer ${peerId}`,
|
||||||
|
peerId,
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build mTLS agent — pin trust to Step-CA root so we never accept
|
||||||
|
// a peer cert signed by a public CA (defense against MITM with a
|
||||||
|
// publicly-trusted DV cert for the peer's hostname).
|
||||||
|
const agent = new Agent({
|
||||||
|
connect: {
|
||||||
|
cert: peer.certPem,
|
||||||
|
key: privateKeyPem,
|
||||||
|
ca: this.loadStepCaRoot(),
|
||||||
|
// rejectUnauthorized: true is the undici default for HTTPS
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry: AgentCacheEntry = {
|
||||||
|
agent,
|
||||||
|
endpointUrl: peer.endpointUrl,
|
||||||
|
certPem: peer.certPem,
|
||||||
|
certSerial: peer.certSerial,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`Agent cached for peer ${peerId} (serial: ${peer.certSerial})`);
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a POST request with a JSON body.
|
||||||
|
* Returns the parsed response body as an unknown value.
|
||||||
|
* Throws `FederationClientError` on network errors and non-2xx responses.
|
||||||
|
*/
|
||||||
|
private async doPost(
|
||||||
|
peerId: string,
|
||||||
|
url: string,
|
||||||
|
agent: Dispatcher,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.doRequest(peerId, url, agent, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a GET request.
|
||||||
|
* Returns the parsed response body as an unknown value.
|
||||||
|
* Throws `FederationClientError` on network errors and non-2xx responses.
|
||||||
|
*/
|
||||||
|
private async doGet(peerId: string, url: string, agent: Dispatcher): Promise<unknown> {
|
||||||
|
return this.doRequest(peerId, url, agent, { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRequest(
|
||||||
|
peerId: string,
|
||||||
|
url: string,
|
||||||
|
agent: Dispatcher,
|
||||||
|
init: { method: string; headers?: Record<string, string>; body?: string },
|
||||||
|
): Promise<unknown> {
|
||||||
|
let response: Awaited<ReturnType<typeof undiciFetch>>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await undiciFetch(url, {
|
||||||
|
...init,
|
||||||
|
dispatcher: agent,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'NETWORK',
|
||||||
|
message: `Network error calling peer ${peerId} at ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
peerId,
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await response.text().catch(() => '');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const status = response.status;
|
||||||
|
|
||||||
|
// Attempt to parse as federation error envelope
|
||||||
|
let serverMessage = `HTTP ${status}`;
|
||||||
|
try {
|
||||||
|
const json: unknown = JSON.parse(rawBody);
|
||||||
|
const result = FederationErrorEnvelopeSchema.safeParse(json);
|
||||||
|
if (result.success) {
|
||||||
|
serverMessage = result.data.error.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON or not a federation envelope — use generic message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific code for 403 (most actionable for callers); generic HTTP_{n} for others
|
||||||
|
const code: FederationClientErrorCode = status === 403 ? 'FORBIDDEN' : `HTTP_${status}`;
|
||||||
|
|
||||||
|
throw new FederationClientError({
|
||||||
|
status,
|
||||||
|
code,
|
||||||
|
message: `Peer ${peerId} returned ${status}: ${serverMessage}`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody) as unknown;
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'INVALID_RESPONSE',
|
||||||
|
message: `Peer ${peerId} returned non-JSON body`,
|
||||||
|
peerId,
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate a response body against a Zod schema.
|
||||||
|
*
|
||||||
|
* For list/get, callers pass the result of `FederationListResponseSchema(z.unknown())`
|
||||||
|
* so that the envelope structure is validated without requiring a concrete item schema
|
||||||
|
* at the client level. The generic `T` provides compile-time typing.
|
||||||
|
*
|
||||||
|
* Throws `FederationClientError({ code: 'INVALID_RESPONSE' })` on parse failure.
|
||||||
|
*/
|
||||||
|
private parseWith<T>(peerId: string, body: unknown, schema: z.ZodTypeAny): T {
|
||||||
|
const result = schema.safeParse(body);
|
||||||
|
if (!result.success) {
|
||||||
|
const issues = result.error.issues
|
||||||
|
.map((e: z.ZodIssue) => `[${e.path.join('.') || 'root'}] ${e.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'INVALID_RESPONSE',
|
||||||
|
message: `Peer ${peerId} returned invalid response shape: ${issues}`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.data as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/gateway/src/federation/client/index.ts
Normal file
13
apps/gateway/src/federation/client/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Federation client barrel — re-exports for FederationModule consumers.
|
||||||
|
*
|
||||||
|
* M3-09 (QuerySourceService) and future milestones should import from here,
|
||||||
|
* not directly from the implementation file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
FederationClientService,
|
||||||
|
FederationClientError,
|
||||||
|
type FederationClientErrorCode,
|
||||||
|
type FederationClientErrorOptions,
|
||||||
|
} from './federation-client.service.js';
|
||||||
@@ -5,11 +5,25 @@ import { EnrollmentController } from './enrollment.controller.js';
|
|||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
|
import { FederationClientService } from './client/index.js';
|
||||||
import { FederationAuthGuard } from './server/index.js';
|
import { FederationAuthGuard } from './server/index.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController],
|
controllers: [EnrollmentController, FederationController],
|
||||||
providers: [AdminGuard, CaService, EnrollmentService, GrantsService, FederationAuthGuard],
|
providers: [
|
||||||
exports: [CaService, EnrollmentService, GrantsService, FederationAuthGuard],
|
AdminGuard,
|
||||||
|
CaService,
|
||||||
|
EnrollmentService,
|
||||||
|
GrantsService,
|
||||||
|
FederationClientService,
|
||||||
|
FederationAuthGuard,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
CaService,
|
||||||
|
EnrollmentService,
|
||||||
|
GrantsService,
|
||||||
|
FederationClientService,
|
||||||
|
FederationAuthGuard,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -22,14 +22,15 @@
|
|||||||
|
|
||||||
These are MVP-level checks that don't belong to any single workstream. Updated by the orchestrator at each session.
|
These are MVP-level checks that don't belong to any single workstream. Updated by the orchestrator at each session.
|
||||||
|
|
||||||
| id | status | description | notes |
|
| id | status | description | notes |
|
||||||
| ------- | ----------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
| ---------- | ----------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| MVP-T01 | done | Author MVP-level manifest at `docs/MISSION-MANIFEST.md` | This session (2026-04-19); PR pending |
|
| MVP-T01 | done | Author MVP-level manifest at `docs/MISSION-MANIFEST.md` | This session (2026-04-19); PR pending |
|
||||||
| MVP-T02 | done | Archive install-ux-v2 mission state to `docs/archive/missions/install-ux-v2-20260405/` | IUV-M03 retroactively closed (shipped via PR #446 + releases 0.0.27→0.0.29) |
|
| MVP-T02 | done | Archive install-ux-v2 mission state to `docs/archive/missions/install-ux-v2-20260405/` | IUV-M03 retroactively closed (shipped via PR #446 + releases 0.0.27→0.0.29) |
|
||||||
| MVP-T03 | done | Land federation v1 planning artifacts on `main` | PR #468 merged 2026-04-19 (commit `66512550`) |
|
| MVP-T03 | done | Land federation v1 planning artifacts on `main` | PR #468 merged 2026-04-19 (commit `66512550`) |
|
||||||
| MVP-T04 | not-started | Sync `.mosaic/orchestrator/mission.json` MVP slot with this manifest (milestone enumeration, etc.) | Coord state file; consider whether to repopulate via `mosaic coord` or accept hand-edit |
|
| MVP-T04 | not-started | Sync `.mosaic/orchestrator/mission.json` MVP slot with this manifest (milestone enumeration, etc.) | Coord state file; consider whether to repopulate via `mosaic coord` or accept hand-edit |
|
||||||
| MVP-T05 | in-progress | Kick off W1 / FED-M1 — federated tier infrastructure | Session 16 (2026-04-19): FED-M1-01 in-progress on `feat/federation-m1-tier-config` |
|
| MVP-T05 | in-progress | Kick off W1 / FED-M1 — federated tier infrastructure | Session 16 (2026-04-19): FED-M1-01 in-progress on `feat/federation-m1-tier-config` |
|
||||||
| MVP-T06 | not-started | Declare additional workstreams (web dashboard, TUI/CLI parity, remote control, etc.) as scope solidifies | Track each new workstream by adding a row to the Workstream Rollup |
|
| MVP-T06 | not-started | Declare additional workstreams (web dashboard, TUI/CLI parity, remote control, etc.) as scope solidifies | Track each new workstream by adding a row to the Workstream Rollup |
|
||||||
|
| T-A292E96F | in-progress | Fix Mosaic Gitea PR metadata/login wrapper regression for U-Connect merge preflight | Kanban `t_a292e96f`; branch `fix/t-a292e96f-gitea-pr-metadata`; scratchpad `docs/scratchpads/t-a292e96f-gitea-pr-metadata.md` |
|
||||||
|
|
||||||
## Pointer to Active Workstream
|
## Pointer to Active Workstream
|
||||||
|
|
||||||
|
|||||||
33
docs/scratchpads/git-wrapper-rollup-20260526.md
Normal file
33
docs/scratchpads/git-wrapper-rollup-20260526.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Git Wrapper Rollup — 2026-05-26
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Consolidate pending Mosaic wrapper fixes after `mosaic update` reported the local framework package was already current (`@mosaicstack/mosaic 0.0.30`) but the installed `~/.config/mosaic/tools` wrappers still lacked the open Gitea/Woodpecker wrapper patches.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Roll up the open wrapper-related Gitea PR branches into one integration branch:
|
||||||
|
|
||||||
|
- PR #513: `pr-ci-wait.sh` stdin collision fix.
|
||||||
|
- PR #518: Gitea PR metadata/merge preflight hardening.
|
||||||
|
- PR #521: Gitea merge fallback + unsafe PR-number rejection.
|
||||||
|
- PR #522: Woodpecker credential/pagination fixes and CI Postgres service collision fix.
|
||||||
|
- PR #523: explicit Gitea repo/login args and `eval` removal for PR/issue creation.
|
||||||
|
|
||||||
|
## Conflict resolutions
|
||||||
|
|
||||||
|
- Kept array-based command construction where possible instead of reintroducing `eval`.
|
||||||
|
- Kept explicit `--repo OWNER/REPO --login mosaicstack` Gitea arguments for `tea` calls.
|
||||||
|
- Combined PR merge API fallback behavior from metadata hardening and empty-identity fallback branches.
|
||||||
|
- Preserved numeric PR-number validation for `pr-merge.sh`.
|
||||||
|
|
||||||
|
## Verification checklist
|
||||||
|
|
||||||
|
- `bash -n` on changed shell scripts.
|
||||||
|
- Wrapper smoke checks from a clean worktree.
|
||||||
|
- Gitea PR verification after push.
|
||||||
|
- CI status checked through Gitea/Woodpecker.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
`mosaic update` did not install these fixes because the package registry still reports `@mosaicstack/mosaic 0.0.30` as current. The source patches must merge/release before normal framework update will carry them.
|
||||||
53
docs/scratchpads/t-a292e96f-gitea-pr-metadata.md
Normal file
53
docs/scratchpads/t-a292e96f-gitea-pr-metadata.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# t_a292e96f — Gitea PR metadata wrapper fix
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Repair Mosaic git wrappers so Gitea PR metadata and merge preflight work for U-Connect PRs on `git.uscllc.com` without selecting the unrelated `git.mosaicstack.dev` tea login.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- Reproduced the failure from `/src/uconnect-worktrees/t_39ce717c-authentik-smoke-gate` with the current `pr-metadata.sh`:
|
||||||
|
- PR #1905 returned JSON with `number=null`, `baseRefName=""`, `headRefName=""`.
|
||||||
|
- PR #1908 returned JSON with `number=null`, `baseRefName=""`, `headRefName=""`.
|
||||||
|
- Root cause: the wrapper treated HTTP/API error payloads as PR payloads and normalized missing fields to empty strings.
|
||||||
|
- The credential loader can return a non-working `git.uscllc.com` API token in this environment, while host-specific `~/.git-credentials` basic auth succeeds. The wrapper now falls back by host before normalization.
|
||||||
|
- `tea login list` has only `git.mosaicstack.dev` configured here; `pr-merge.sh` previously forced `--login mosaicstack`, which is invalid for `git.uscllc.com` and caused `Login name mosaicstack does not exist`.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- `packages/mosaic/framework/tools/git/detect-platform.sh`
|
||||||
|
- Added `get_gitea_basic_auth <host>` to retrieve host-specific HTTPS credentials from `~/.git-credentials` without printing secrets.
|
||||||
|
- `packages/mosaic/framework/tools/git/pr-metadata.sh`
|
||||||
|
- Uses strict bash mode.
|
||||||
|
- Checks Gitea HTTP status and fails nonzero on API errors/non-JSON instead of emitting empty branch fields.
|
||||||
|
- Falls back from token auth to host-specific basic auth.
|
||||||
|
- Normalizes standard `head.ref`/`base.ref` and fallback branch fields.
|
||||||
|
- Requires non-empty `headRefName` and `baseRefName`.
|
||||||
|
- Preserves GitHub `gh pr view` behavior.
|
||||||
|
- `packages/mosaic/framework/tools/git/pr-merge.sh`
|
||||||
|
- Reads metadata once for base-branch policy preflight.
|
||||||
|
- Selects a `tea` login only when its configured URL matches the repo host.
|
||||||
|
- Falls back to authenticated Gitea merge API when no matching `tea` login exists, avoiding the wrong `mosaicstack` login for USC repos.
|
||||||
|
- Keeps squash-only and main-only merge policy.
|
||||||
|
- `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
|
||||||
|
- Added fixture-based regression harness for standard Gitea fields, fallback branch fields, `refs/pull/<n>/head` plus `head.label` normalization, and API error payloads.
|
||||||
|
|
||||||
|
## Documentation / changelog note
|
||||||
|
|
||||||
|
This repository currently has no root `CHANGELOG.md`; the scratchpad and `docs/TASKS.md` carry the task-level change record for this wrapper fix.
|
||||||
|
|
||||||
|
## Verification log
|
||||||
|
|
||||||
|
- Red regression check: copied the new `test-pr-metadata-gitea.sh` harness next to `origin/main` wrapper scripts and ran it with `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-gitea-red`; it failed as expected with `headRefName=''` and `baseRefName=''` on the fixture API-error path.
|
||||||
|
- `bash -n packages/mosaic/framework/tools/git/{detect-platform.sh,pr-metadata.sh,pr-merge.sh,test-pr-metadata-gitea.sh}`: passed.
|
||||||
|
- `shellcheck -x -P . -e SC1090 packages/mosaic/framework/tools/git/{detect-platform.sh,pr-metadata.sh,pr-merge.sh,test-pr-metadata-gitea.sh}`: passed.
|
||||||
|
- `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-gitea packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`: passed; verifies standard Gitea fields, fallback branch fields, `refs/pull/<n>/head` label normalization, and nonzero API-error handling.
|
||||||
|
- Installed wrapper parity: `/home/hermes/.config/mosaic/tools/git/{detect-platform.sh,pr-metadata.sh,pr-merge.sh}` byte-match the PR source copies after validation, so active U-Connect wrapper invocations use the same fix while source PR review runs.
|
||||||
|
- Live sanitized U-Connect metadata from `/src/uconnect` with `MOSAIC_CREDENTIALS_FILE=/src/jarvis-brain/credentials.json`:
|
||||||
|
- PR #1905: `number=1905`, `baseRefName=main`, `headRefName=edith/t_39ce717c-authentik-smoke-gate`, `state=open`, `host=git.uscllc.com`.
|
||||||
|
- PR #1908: `number=1908`, `baseRefName=main`, `headRefName=fix/t_23fa9e1d-portal-health-backend`, `state=closed`, `host=git.uscllc.com`.
|
||||||
|
- Merge preflight dry runs from installed wrappers:
|
||||||
|
- PR #1905: `Dry run: would merge PR #1905 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).`
|
||||||
|
- PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).`
|
||||||
|
- PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`.
|
||||||
|
- CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean.
|
||||||
31
docs/scratchpads/t_301e4e3b-pr-merge-gitea-empty-uid.md
Normal file
31
docs/scratchpads/t_301e4e3b-pr-merge-gitea-empty-uid.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Scratchpad: t_301e4e3b pr-merge.sh Gitea empty-uid fallback
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Implement a narrow hardening in `packages/mosaic/framework/tools/git/pr-merge.sh` so Gitea merges recover from the known non-interactive `tea pr merge` identity failure: `user does not exist [uid: 0, name: ]`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Preserve Mosaic policy gates: squash-only, base branch `main`, queue guard unless explicitly skipped.
|
||||||
|
- Preserve the existing authenticated Gitea API fallback when no tea login exists.
|
||||||
|
- Do not fallback on arbitrary tea failures.
|
||||||
|
- Do not expose tokens or credential-bearing remotes.
|
||||||
|
- Scope is limited to the merge wrapper plus focused test/support/scratchpad files.
|
||||||
|
|
||||||
|
## External issue
|
||||||
|
|
||||||
|
- Gitea issue #520: Harden pr-merge.sh Gitea empty-uid fallback
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add a focused shell regression harness with mocked `tea` and `curl` proving the known empty uid/name failure must fall back to Gitea API.
|
||||||
|
2. Watch the harness fail on current code.
|
||||||
|
3. Implement helper functions in `pr-merge.sh` for redacted command display, known failure classification, and authenticated Gitea API merge fallback.
|
||||||
|
4. Keep unknown `tea` failures blocking by replaying stderr and exiting non-zero.
|
||||||
|
5. Run syntax, shellcheck if available, focused regression, and repo quality gates before push/PR.
|
||||||
|
|
||||||
|
## Session log
|
||||||
|
|
||||||
|
- 2026-05-22: Read Kanban context, Mosaic global/repo instructions, created isolated branch `fix/t_301e4e3b-pr-merge-gitea-empty-uid`, and opened Gitea issue #520 using the Mosaic issue wrapper/API fallback.
|
||||||
|
- 2026-05-22: Added regression harness and watched it fail on current behavior with `user does not exist [uid: 0, name: ]`; implemented narrow fallback and verified known-empty-identity fallback, arbitrary tea failure blocking, and no-tea-login API fallback paths.
|
||||||
|
- 2026-05-22: Validation passed for `bash -n`, `shellcheck -x`, focused shell harness, `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `pnpm --filter @mosaicstack/mosaic test`. Full `pnpm test` exposed an out-of-scope gateway DB setup failure (`relation "messages" does not exist`) in `apps/gateway/src/__tests__/cross-user-isolation.test.ts`.
|
||||||
48
docs/scratchpads/t_5aab9cc8-pr-merge-eval-injection.md
Normal file
48
docs/scratchpads/t_5aab9cc8-pr-merge-eval-injection.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# t_5aab9cc8 — pr-merge.sh eval injection remediation
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Remediate PR #521 review blocker: `packages/mosaic/framework/tools/git/pr-merge.sh` must reject non-numeric PR numbers before metadata lookup/merge and must not use `eval` for GitHub merge execution.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Shell wrapper only: `packages/mosaic/framework/tools/git/pr-merge.sh`
|
||||||
|
- Focused regression harness: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
||||||
|
- No API/frontend/infra surfaces.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- AC1: `PR_NUMBER` is validated as digits-only immediately after required-argument parsing, before metadata lookup.
|
||||||
|
- AC2: GitHub merge path uses a quoted argv array, not command-string construction plus `eval`.
|
||||||
|
- AC3: Focused tests prove PR-number metacharacters are rejected and cannot execute injected shell commands on GitHub path.
|
||||||
|
- AC4: Focused tests prove PR-number metacharacters are rejected on Gitea path before tea/curl merge calls.
|
||||||
|
- AC5: Existing Gitea empty-uid fallback behavior remains green.
|
||||||
|
- AC6: Syntax, shellcheck where available, focused harness, and relevant repo gates are rerun or absence documented.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add failing regression tests for GitHub eval injection and Gitea invalid PR rejection.
|
||||||
|
2. Implement fail-closed PR number validation before metadata lookup.
|
||||||
|
3. Replace GitHub `eval` command with argv array execution.
|
||||||
|
4. Run required validation and update this scratchpad with evidence.
|
||||||
|
5. Commit, queue-guard, push branch, update PR #521.
|
||||||
|
|
||||||
|
## TDD Log
|
||||||
|
|
||||||
|
- RED: `AGENT_WORK_ROOT="$HERMES_KANBAN_WORKSPACE/work" bash packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh` failed on vulnerable code with `Expected GitHub metacharacter PR number to be rejected` and showed the injected PR number reached the GitHub merge path.
|
||||||
|
- GREEN: Added digits-only validation before metadata lookup and replaced GitHub `eval` with an argv array. The focused harness now passes and verifies invalid PR numbers are rejected before GitHub `gh` calls and before Gitea `tea`/`curl` calls.
|
||||||
|
|
||||||
|
## Validation Evidence
|
||||||
|
|
||||||
|
- PASS: `AGENT_WORK_ROOT="$HERMES_KANBAN_WORKSPACE/work" bash -n packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
||||||
|
- PASS: `shellcheck -x packages/mosaic/framework/tools/git/pr-merge.sh packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
||||||
|
- PASS: `AGENT_WORK_ROOT="$HERMES_KANBAN_WORKSPACE/work" bash packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
||||||
|
- PASS: `pnpm --filter @mosaicstack/mosaic... build`
|
||||||
|
- PASS: `pnpm --filter @mosaicstack/mosaic lint`
|
||||||
|
- PASS: `pnpm --filter @mosaicstack/mosaic typecheck`
|
||||||
|
- PASS: `pnpm --filter @mosaicstack/mosaic test` — 32 files / 291 tests passed.
|
||||||
|
- REVIEW: `/home/hermes/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` could not run due Codex 401 Unauthorized. Independent delegate review completed read-only with PASS / no blockers; non-blocking suggestion to assert GitHub mock log remains empty was applied.
|
||||||
|
|
||||||
|
## Risks / Blockers
|
||||||
|
|
||||||
|
- No active blockers.
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist",
|
||||||
|
"drizzle"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { PGlite } from '@electric-sql/pglite';
|
import { PGlite } from '@electric-sql/pglite';
|
||||||
|
import { vector } from '@electric-sql/pglite/vector';
|
||||||
import { drizzle } from 'drizzle-orm/pglite';
|
import { drizzle } from 'drizzle-orm/pglite';
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import type { DbHandle } from './client.js';
|
import type { DbHandle } from './client.js';
|
||||||
|
|
||||||
export function createPgliteDb(dataDir: string): DbHandle {
|
export function createPgliteDb(dataDir: string): DbHandle {
|
||||||
const client = new PGlite(dataDir);
|
// pgvector extension is required by migration 0001 (insights.embedding column).
|
||||||
|
const client = new PGlite(dataDir, { extensions: { vector } });
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
return {
|
return {
|
||||||
db: db as unknown as DbHandle['db'],
|
db: db as unknown as DbHandle['db'],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export { createDb, type Db, type DbHandle } from './client.js';
|
export { createDb, type Db, type DbHandle } from './client.js';
|
||||||
export { createPgliteDb } from './client-pglite.js';
|
export { createPgliteDb } from './client-pglite.js';
|
||||||
export { runMigrations } from './migrate.js';
|
export { runMigrations, runPgliteMigrations } from './migrate.js';
|
||||||
export * from './schema.js';
|
export * from './schema.js';
|
||||||
export * from './federation.js';
|
export * from './federation.js';
|
||||||
export {
|
export {
|
||||||
|
|||||||
70
packages/db/src/migrate.test.ts
Normal file
70
packages/db/src/migrate.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { createPgliteDb } from './client-pglite.js';
|
||||||
|
import { runPgliteMigrations } from './migrate.js';
|
||||||
|
import type { DbHandle } from './client.js';
|
||||||
|
|
||||||
|
interface PgliteExec {
|
||||||
|
exec(query: string): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runPgliteMigrations', () => {
|
||||||
|
let dataDir: string;
|
||||||
|
let handle: DbHandle;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'mosaic-db-migrate-test-'));
|
||||||
|
handle = createPgliteDb(dataDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await handle.close();
|
||||||
|
rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the BetterAuth tables required by the gateway', async () => {
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
|
||||||
|
const result = (await handle.db.execute(sql`
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
ORDER BY table_name
|
||||||
|
`)) as unknown as { rows: Array<{ table_name: string }> };
|
||||||
|
|
||||||
|
const tables = result.rows.map((r) => r.table_name);
|
||||||
|
|
||||||
|
// Auth tables — required for sign-in / bootstrap to function.
|
||||||
|
expect(tables).toContain('users');
|
||||||
|
expect(tables).toContain('sessions');
|
||||||
|
expect(tables).toContain('accounts');
|
||||||
|
expect(tables).toContain('verifications');
|
||||||
|
|
||||||
|
// Schema sanity check — admin token table consumed by mosaic gateway config.
|
||||||
|
expect(tables).toContain('admin_tokens');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — running twice does not error', async () => {
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
|
await expect(runPgliteMigrations(handle)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces statement-level error context on failure and leaves no ledger row', async () => {
|
||||||
|
// Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE,
|
||||||
|
// forcing it to fail without IF NOT EXISTS.
|
||||||
|
const client = (handle.db as unknown as { $client: PgliteExec }).$client;
|
||||||
|
await client.exec('CREATE TABLE users (sentinel text)');
|
||||||
|
|
||||||
|
await expect(runPgliteMigrations(handle)).rejects.toThrow(
|
||||||
|
/migration hash=[a-f0-9]+ statement #\d+ failed/,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ledger should be empty — partial application must not pretend to be complete.
|
||||||
|
const ledger = (await handle.db.execute(
|
||||||
|
sql`SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations`,
|
||||||
|
)) as unknown as { rows: Array<{ count: number }> };
|
||||||
|
expect(ledger.rows[0]?.count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,109 @@
|
|||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
import { sql } from 'drizzle-orm';
|
||||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js';
|
||||||
|
import { migrate as migratePostgres } from 'drizzle-orm/postgres-js/migrator';
|
||||||
|
import { readMigrationFiles } from 'drizzle-orm/migrator';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { DEFAULT_DATABASE_URL } from './defaults.js';
|
import { DEFAULT_DATABASE_URL } from './defaults.js';
|
||||||
|
import type { DbHandle } from './client.js';
|
||||||
|
|
||||||
|
interface PgliteExecutor {
|
||||||
|
exec(query: string): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecuteRows<T> {
|
||||||
|
rows: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationsFolder(): string {
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
return resolve(here, '../drizzle');
|
||||||
|
}
|
||||||
|
|
||||||
export async function runMigrations(url?: string): Promise<void> {
|
export async function runMigrations(url?: string): Promise<void> {
|
||||||
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
||||||
const sql = postgres(connectionString, { max: 1 });
|
const sqlClient = postgres(connectionString, { max: 1 });
|
||||||
const db = drizzle(sql);
|
const db = drizzlePostgres(sqlClient);
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
try {
|
try {
|
||||||
await migrate(db, { migrationsFolder: resolve(__dirname, '../drizzle') });
|
// TODO: postgres-tier first-install also fails because (a) Drizzle wraps every
|
||||||
|
// migration in one transaction (breaks 0009's ALTER TYPE ADD VALUE → SET DEFAULT
|
||||||
|
// sequence) and (b) drizzle/meta/_journal.json has 0009 ordered before 0008,
|
||||||
|
// which the postgres-js migrator skips by `created_at < folderMillis`. The
|
||||||
|
// PGlite path below sidesteps both. A follow-up should either share the
|
||||||
|
// per-statement loop (see runPgliteMigrations) or fix the journal ordering.
|
||||||
|
await migratePostgres(db, { migrationsFolder: migrationsFolder() });
|
||||||
} finally {
|
} finally {
|
||||||
await sql.end();
|
await sqlClient.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Drizzle migrations against an embedded PGlite database.
|
||||||
|
//
|
||||||
|
// We don't reuse drizzle's pglite migrator because it wraps ALL migrations in
|
||||||
|
// one outer transaction, which breaks Postgres' `check_safe_enum_use` rule —
|
||||||
|
// e.g. migration 0009 does `ALTER TYPE ADD VALUE 'pending'` then references
|
||||||
|
// `'pending'` as a default in the same tx. PGlite's `exec()` runs each
|
||||||
|
// statement under the Simple Query protocol, autocommitting between them.
|
||||||
|
//
|
||||||
|
// We still write to the standard `drizzle.__drizzle_migrations` ledger so the
|
||||||
|
// result is interoperable with `runMigrations()` on a postgres-backed deploy
|
||||||
|
// (modulo the journal-ordering bug noted above).
|
||||||
|
//
|
||||||
|
// We skip-by-hash rather than skip-by-folderMillis (which is what Drizzle's
|
||||||
|
// postgres-js migrator does). That's deliberate — out-of-order timestamps in
|
||||||
|
// `_journal.json` won't silently drop migrations.
|
||||||
|
//
|
||||||
|
// Failure model: each statement autocommits, and the ledger row is written
|
||||||
|
// only after all statements in a migration succeed. A crash mid-migration
|
||||||
|
// leaves the prefix applied with no ledger entry, so the next boot will
|
||||||
|
// replay those statements and fail loudly on "already exists". Recovery:
|
||||||
|
// drop the partially-applied objects, or insert the migration's hash into
|
||||||
|
// `drizzle.__drizzle_migrations` manually. The error log identifies which
|
||||||
|
// statement of which migration was the culprit.
|
||||||
|
export async function runPgliteMigrations(handle: DbHandle): Promise<void> {
|
||||||
|
const client = (handle.db as unknown as { $client?: PgliteExecutor }).$client;
|
||||||
|
if (!client || typeof client.exec !== 'function') {
|
||||||
|
throw new Error('runPgliteMigrations: handle.db is not backed by a PGlite client');
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.exec('CREATE SCHEMA IF NOT EXISTS drizzle');
|
||||||
|
await client.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hash text NOT NULL,
|
||||||
|
created_at bigint
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const appliedRows = (await handle.db.execute(
|
||||||
|
sql`SELECT hash FROM drizzle.__drizzle_migrations`,
|
||||||
|
)) as unknown as ExecuteRows<{ hash: string }>;
|
||||||
|
const applied = new Set(appliedRows.rows.map((r) => r.hash));
|
||||||
|
|
||||||
|
const migrations = readMigrationFiles({ migrationsFolder: migrationsFolder() });
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (applied.has(migration.hash)) continue;
|
||||||
|
|
||||||
|
// Run each statement-breakpoint chunk in its own exec() call so PGlite
|
||||||
|
// commits between statements — this is what lets `ALTER TYPE ADD VALUE`
|
||||||
|
// become visible before a subsequent statement references the new value.
|
||||||
|
for (const [stmtIdx, stmt] of migration.sql.entries()) {
|
||||||
|
const trimmed = stmt.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
await client.exec(trimmed);
|
||||||
|
} catch (err) {
|
||||||
|
const cause = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(
|
||||||
|
`runPgliteMigrations: migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` +
|
||||||
|
`Statement: ${trimmed.slice(0, 200)}${trimmed.length > 200 ? '…' : ''}`,
|
||||||
|
{ cause: err },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await handle.db.execute(
|
||||||
|
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${migration.hash}, ${migration.folderMillis})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,20 @@ _mosaic_sync_woodpecker_env() {
|
|||||||
printf '%s\n' "$expected" > "$env_file"
|
printf '%s\n' "$expected" > "$env_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Load legacy flat Woodpecker credentials (.woodpecker.url / .woodpecker.token).
|
||||||
|
# Some environments export WOODPECKER_INSTANCE=mosaic, but the current
|
||||||
|
# credentials.json may still use the legacy flat schema. Treat "mosaic" as the
|
||||||
|
# default flat instance when a nested .woodpecker.mosaic object is absent.
|
||||||
|
_mosaic_load_woodpecker_legacy() {
|
||||||
|
export WOODPECKER_URL="$(_mosaic_read_cred '.woodpecker.url')"
|
||||||
|
export WOODPECKER_TOKEN="$(_mosaic_read_cred '.woodpecker.token')"
|
||||||
|
export WOODPECKER_INSTANCE="${WOODPECKER_INSTANCE:-mosaic}"
|
||||||
|
WOODPECKER_URL="${WOODPECKER_URL%/}"
|
||||||
|
[[ -n "$WOODPECKER_URL" ]] || { echo "Error: woodpecker.url not found" >&2; return 1; }
|
||||||
|
[[ -n "$WOODPECKER_TOKEN" ]] || { echo "Error: woodpecker.token not found" >&2; return 1; }
|
||||||
|
_mosaic_sync_woodpecker_env "$WOODPECKER_INSTANCE" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
|
||||||
|
}
|
||||||
|
|
||||||
load_credentials() {
|
load_credentials() {
|
||||||
local service="$1"
|
local service="$1"
|
||||||
|
|
||||||
@@ -155,7 +169,14 @@ EOF
|
|||||||
;;
|
;;
|
||||||
woodpecker-*)
|
woodpecker-*)
|
||||||
local wp_instance="${service#woodpecker-}"
|
local wp_instance="${service#woodpecker-}"
|
||||||
# credentials.json is authoritative — always read from it, ignore env
|
# credentials.json is authoritative — always read from it, ignore env.
|
||||||
|
# Backward compatibility: the default Mosaic Woodpecker instance may be
|
||||||
|
# stored in the legacy flat schema (.woodpecker.url/.token) instead of
|
||||||
|
# .woodpecker.mosaic.url/.token.
|
||||||
|
if [[ "$wp_instance" == "mosaic" ]] && [[ -z "$(_mosaic_read_cred '.woodpecker.mosaic.url')" ]] && [[ -n "$(_mosaic_read_cred '.woodpecker.url')" ]]; then
|
||||||
|
WOODPECKER_INSTANCE="mosaic" _mosaic_load_woodpecker_legacy
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
export WOODPECKER_URL="$(_mosaic_read_cred ".woodpecker.${wp_instance}.url")"
|
export WOODPECKER_URL="$(_mosaic_read_cred ".woodpecker.${wp_instance}.url")"
|
||||||
export WOODPECKER_TOKEN="$(_mosaic_read_cred ".woodpecker.${wp_instance}.token")"
|
export WOODPECKER_TOKEN="$(_mosaic_read_cred ".woodpecker.${wp_instance}.token")"
|
||||||
export WOODPECKER_INSTANCE="$wp_instance"
|
export WOODPECKER_INSTANCE="$wp_instance"
|
||||||
@@ -166,7 +187,10 @@ EOF
|
|||||||
_mosaic_sync_woodpecker_env "$wp_instance" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
|
_mosaic_sync_woodpecker_env "$wp_instance" "$WOODPECKER_URL" "$WOODPECKER_TOKEN"
|
||||||
;;
|
;;
|
||||||
woodpecker)
|
woodpecker)
|
||||||
# Resolve default instance, then load it
|
# Resolve default instance, then load it. If WOODPECKER_INSTANCE is set to
|
||||||
|
# "mosaic" by a shell/profile but credentials.json still uses the legacy
|
||||||
|
# flat .woodpecker.url/.token schema, load the flat credentials instead of
|
||||||
|
# failing with "woodpecker.mosaic.url not found".
|
||||||
local wp_default
|
local wp_default
|
||||||
wp_default="${WOODPECKER_INSTANCE:-$(_mosaic_read_cred '.woodpecker.default')}"
|
wp_default="${WOODPECKER_INSTANCE:-$(_mosaic_read_cred '.woodpecker.default')}"
|
||||||
if [[ -z "$wp_default" ]]; then
|
if [[ -z "$wp_default" ]]; then
|
||||||
@@ -174,18 +198,18 @@ EOF
|
|||||||
local legacy_url
|
local legacy_url
|
||||||
legacy_url="$(_mosaic_read_cred '.woodpecker.url')"
|
legacy_url="$(_mosaic_read_cred '.woodpecker.url')"
|
||||||
if [[ -n "$legacy_url" ]]; then
|
if [[ -n "$legacy_url" ]]; then
|
||||||
export WOODPECKER_URL="${WOODPECKER_URL:-$legacy_url}"
|
_mosaic_load_woodpecker_legacy
|
||||||
export WOODPECKER_TOKEN="${WOODPECKER_TOKEN:-$(_mosaic_read_cred '.woodpecker.token')}"
|
|
||||||
WOODPECKER_URL="${WOODPECKER_URL%/}"
|
|
||||||
[[ -n "$WOODPECKER_URL" ]] || { echo "Error: woodpecker.url not found" >&2; return 1; }
|
|
||||||
[[ -n "$WOODPECKER_TOKEN" ]] || { echo "Error: woodpecker.token not found" >&2; return 1; }
|
|
||||||
else
|
else
|
||||||
echo "Error: woodpecker.default not set and no WOODPECKER_INSTANCE env var" >&2
|
echo "Error: woodpecker.default not set and no WOODPECKER_INSTANCE env var" >&2
|
||||||
echo "Available instances: $(jq -r '.woodpecker | keys | join(", ")' "$MOSAIC_CREDENTIALS_FILE" 2>/dev/null)" >&2
|
echo "Available instances: $(jq -r '.woodpecker | keys | join(", ")' "$MOSAIC_CREDENTIALS_FILE" 2>/dev/null)" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
load_credentials "woodpecker-${wp_default}"
|
if [[ "$wp_default" == "mosaic" ]] && [[ -z "$(_mosaic_read_cred '.woodpecker.mosaic.url')" ]] && [[ -n "$(_mosaic_read_cred '.woodpecker.url')" ]]; then
|
||||||
|
WOODPECKER_INSTANCE="mosaic" _mosaic_load_woodpecker_legacy
|
||||||
|
else
|
||||||
|
load_credentials "woodpecker-${wp_default}"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
cloudflare-*)
|
cloudflare-*)
|
||||||
|
|||||||
@@ -74,6 +74,16 @@ get_repo_name() {
|
|||||||
echo "${repo_info##*/}"
|
echo "${repo_info##*/}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_repo_slug() {
|
||||||
|
get_repo_info
|
||||||
|
}
|
||||||
|
|
||||||
|
get_gitea_repo_args() {
|
||||||
|
local repo
|
||||||
|
repo=$(get_repo_slug) || return 1
|
||||||
|
printf -- '--repo %q --login %q' "$repo" "${GITEA_LOGIN:-mosaicstack}"
|
||||||
|
}
|
||||||
|
|
||||||
get_remote_host() {
|
get_remote_host() {
|
||||||
local remote_url
|
local remote_url
|
||||||
remote_url=$(git remote get-url origin 2>/dev/null || true)
|
remote_url=$(git remote get-url origin 2>/dev/null || true)
|
||||||
@@ -103,16 +113,28 @@ get_gitea_token() {
|
|||||||
if [[ -f "$cred_loader" ]]; then
|
if [[ -f "$cred_loader" ]]; then
|
||||||
local token
|
local token
|
||||||
token=$(
|
token=$(
|
||||||
|
# shellcheck source=/dev/null
|
||||||
source "$cred_loader"
|
source "$cred_loader"
|
||||||
|
# Host-specific wrapper resolution must not inherit caller/global GITEA_*.
|
||||||
|
# load_credentials intentionally preserves existing env vars for interactive use,
|
||||||
|
# but metadata/merge wrappers need credentials matching the remote host.
|
||||||
|
unset GITEA_TOKEN GITEA_URL
|
||||||
case "$host" in
|
case "$host" in
|
||||||
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
||||||
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
||||||
*)
|
*)
|
||||||
|
local matched=false
|
||||||
for svc in gitea-mosaicstack gitea-usc; do
|
for svc in gitea-mosaicstack gitea-usc; do
|
||||||
load_credentials "$svc" 2>/dev/null || continue
|
|
||||||
[[ "${GITEA_URL:-}" == *"$host"* ]] && break
|
|
||||||
unset GITEA_TOKEN GITEA_URL
|
unset GITEA_TOKEN GITEA_URL
|
||||||
|
load_credentials "$svc" 2>/dev/null || continue
|
||||||
|
if [[ "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
||||||
|
matched=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
if [[ "$matched" != true ]]; then
|
||||||
|
unset GITEA_TOKEN GITEA_URL
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
echo "${GITEA_TOKEN:-}"
|
echo "${GITEA_TOKEN:-}"
|
||||||
@@ -123,10 +145,12 @@ get_gitea_token() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. GITEA_TOKEN env var (may be set by caller)
|
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||||
echo "$GITEA_TOKEN"
|
if [[ -z "${GITEA_URL:-}" || "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
||||||
return 0
|
echo "$GITEA_TOKEN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. ~/.git-credentials file
|
# 3. ~/.git-credentials file
|
||||||
@@ -143,6 +167,37 @@ get_gitea_token() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
|
||||||
|
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
||||||
|
get_gitea_basic_auth() {
|
||||||
|
local host="$1"
|
||||||
|
local creds="$HOME/.git-credentials"
|
||||||
|
if [[ ! -f "$creds" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$host" "$creds" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
host = sys.argv[1]
|
||||||
|
creds = Path(sys.argv[2])
|
||||||
|
|
||||||
|
for line in creds.read_text(encoding="utf-8").splitlines():
|
||||||
|
parsed = urlparse(line.strip())
|
||||||
|
if parsed.hostname != host:
|
||||||
|
continue
|
||||||
|
username = unquote(parsed.username or "")
|
||||||
|
password = unquote(parsed.password or "")
|
||||||
|
if username and password:
|
||||||
|
print(f"{username}:{password}")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
# If script is run directly (not sourced), output the platform
|
# If script is run directly (not sourced), output the platform
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
detect_platform
|
detect_platform
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT"
|
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||||
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -112,20 +112,22 @@ PLATFORM=$(detect_platform)
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
CMD="gh issue create --title \"$TITLE\""
|
CMD=(gh issue create --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --body \"$BODY\""
|
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||||
[[ -n "$LABELS" ]] && CMD="$CMD --label \"$LABELS\""
|
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
eval "$CMD"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
if command -v tea >/dev/null 2>&1; then
|
if command -v tea >/dev/null 2>&1; then
|
||||||
CMD="tea issue create --title \"$TITLE\""
|
REPO_SLUG=$(get_repo_slug)
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "${GITEA_LOGIN:-mosaicstack}")
|
||||||
[[ -n "$LABELS" ]] && CMD="$CMD --labels \"$LABELS\""
|
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
[[ -n "$LABELS" ]] && CMD+=(--labels "$LABELS")
|
||||||
# tea accepts milestone by name directly (verified 2026-02-05)
|
# tea accepts milestone by name directly (verified 2026-02-05)
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
if eval "$CMD"; then
|
if "${CMD[@]}"; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-list.sh - List issues on Gitea or GitHub
|
# issue-list.sh - List issues on Gitea or GitHub
|
||||||
# Usage: issue-list.sh [-s state] [-l label] [-m milestone] [-a assignee]
|
# Usage: issue-list.sh [-r owner/repo] [-s state] [-l label] [-m milestone] [-a assignee]
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ LABEL=""
|
|||||||
MILESTONE=""
|
MILESTONE=""
|
||||||
ASSIGNEE=""
|
ASSIGNEE=""
|
||||||
LIMIT=100
|
LIMIT=100
|
||||||
|
REPO_OVERRIDE=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -26,12 +27,14 @@ Options:
|
|||||||
-m, --milestone NAME Filter by milestone name
|
-m, --milestone NAME Filter by milestone name
|
||||||
-a, --assignee USER Filter by assignee
|
-a, --assignee USER Filter by assignee
|
||||||
-n, --limit N Maximum issues to show (default: 100)
|
-n, --limit N Maximum issues to show (default: 100)
|
||||||
|
-r, --repo OWNER/REPO Repository slug (default: infer from git origin)
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") # List open issues
|
$(basename "$0") # List open issues
|
||||||
$(basename "$0") -s all -l bug # All issues with 'bug' label
|
$(basename "$0") -s all -l bug # All issues with 'bug' label
|
||||||
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
||||||
|
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
LIMIT="$2"
|
LIMIT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-r|--repo)
|
||||||
|
REPO_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
@@ -69,25 +76,34 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
PLATFORM=$(detect_platform)
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
REPO_INFO="$REPO_OVERRIDE"
|
||||||
|
PLATFORM=$(detect_platform 2>/dev/null || echo gitea)
|
||||||
|
else
|
||||||
|
PLATFORM=$(detect_platform)
|
||||||
|
REPO_INFO=$(get_repo_info)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$REPO_INFO" || "$REPO_INFO" == error:* ]]; then
|
||||||
|
echo "Error: Could not determine repository from git origin. Run from a repo or pass --repo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
CMD="gh issue list --state $STATE --limit $LIMIT"
|
CMD=(gh issue list --repo "$REPO_INFO" --state "$STATE" --limit "$LIMIT")
|
||||||
[[ -n "$LABEL" ]] && CMD="$CMD --label \"$LABEL\""
|
[[ -n "$LABEL" ]] && CMD+=(--label "$LABEL")
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
[[ -n "$ASSIGNEE" ]] && CMD="$CMD --assignee \"$ASSIGNEE\""
|
[[ -n "$ASSIGNEE" ]] && CMD+=(--assignee "$ASSIGNEE")
|
||||||
eval "$CMD"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
CMD="tea issues list --state $STATE --limit $LIMIT"
|
CMD=(tea issues list --repo "$REPO_INFO" --login "${GITEA_LOGIN:-mosaicstack}" --state "$STATE" --limit "$LIMIT")
|
||||||
[[ -n "$LABEL" ]] && CMD="$CMD --labels \"$LABEL\""
|
[[ -n "$LABEL" ]] && CMD+=(--labels "$LABEL")
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestones \"$MILESTONE\""
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestones "$MILESTONE")
|
||||||
# Note: tea may not support assignee filter directly
|
# Note: tea may not support assignee filter directly in all versions.
|
||||||
eval "$CMD"
|
[[ -n "$ASSIGNEE" ]] && echo "Note: Assignee filtering may require manual review for Gitea" >&2
|
||||||
if [[ -n "$ASSIGNEE" ]]; then
|
"${CMD[@]}"
|
||||||
echo "Note: Assignee filtering may require manual review for Gitea" >&2
|
|
||||||
fi
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
echo "Reopened GitHub issue #$ISSUE_NUMBER"
|
echo "Reopened GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT"
|
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||||
fi
|
fi
|
||||||
tea issue reopen "$ISSUE_NUMBER"
|
tea issue reopen "$ISSUE_NUMBER" $(get_gitea_repo_args)
|
||||||
echo "Reopened Gitea issue #$ISSUE_NUMBER"
|
echo "Reopened Gitea issue #$ISSUE_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue view "$ISSUE_NUMBER"
|
gh issue view "$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
if command -v tea >/dev/null 2>&1; then
|
if command -v tea >/dev/null 2>&1; then
|
||||||
if tea issue "$ISSUE_NUMBER"; then
|
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args); then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-ci-wait.sh - Wait for PR CI status to reach terminal state (GitHub/Gitea)
|
# pr-ci-wait.sh - Wait for PR CI status to reach terminal state (GitHub/Gitea)
|
||||||
# Usage: pr-ci-wait.sh -n <pr_number> [-t timeout_sec] [-i interval_sec]
|
# Usage: pr-ci-wait.sh -n <pr_number> [-r owner/repo] [-t timeout_sec] [-i interval_sec]
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
TIMEOUT_SEC=1800
|
TIMEOUT_SEC=1800
|
||||||
INTERVAL_SEC=15
|
INTERVAL_SEC=15
|
||||||
|
REPO_OVERRIDE=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -17,12 +18,14 @@ Usage: $(basename "$0") -n <pr_number> [-t timeout_sec] [-i interval_sec]
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
-n, --number NUMBER PR number (required)
|
-n, --number NUMBER PR number (required)
|
||||||
|
-r, --repo OWNER/REPO Repository slug (default: infer from git origin)
|
||||||
-t, --timeout SECONDS Max wait time in seconds (default: 1800)
|
-t, --timeout SECONDS Max wait time in seconds (default: 1800)
|
||||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||||
-h, --help Show this help
|
-h, --help Show this help
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -n 643
|
$(basename "$0") -n 643
|
||||||
|
$(basename "$0") -n 643 --repo ddk/ai-bma
|
||||||
$(basename "$0") -n 643 -t 900 -i 10
|
$(basename "$0") -n 643 -t 900 -i 10
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -30,12 +33,19 @@ EOF
|
|||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
|
|
||||||
extract_state_from_status_json() {
|
extract_state_from_status_json() {
|
||||||
python3 - <<'PY'
|
# Capture piped JSON BEFORE invoking `python3 - <<PY`. The heredoc binds
|
||||||
|
# stdin to the Python program text — so json.load(sys.stdin) inside would
|
||||||
|
# try to re-read stdin after `-` already consumed it for the program,
|
||||||
|
# yielding EOF and returning "unknown" every time. Pass payload via env.
|
||||||
|
local payload
|
||||||
|
payload=$(cat)
|
||||||
|
PR_CI_STATUS_JSON="$payload" python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.load(sys.stdin)
|
payload = json.loads(os.environ.get("PR_CI_STATUS_JSON", ""))
|
||||||
except Exception:
|
except Exception:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
@@ -66,12 +76,16 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
print_status_summary() {
|
print_status_summary() {
|
||||||
python3 - <<'PY'
|
# Same stdin-collision fix as extract_state_from_status_json above.
|
||||||
|
local payload
|
||||||
|
payload=$(cat)
|
||||||
|
PR_CI_STATUS_JSON="$payload" python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.load(sys.stdin)
|
payload = json.loads(os.environ.get("PR_CI_STATUS_JSON", ""))
|
||||||
except Exception:
|
except Exception:
|
||||||
print("[pr-ci-wait] status payload unavailable")
|
print("[pr-ci-wait] status payload unavailable")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
@@ -95,7 +109,7 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
github_get_pr_head_sha() {
|
github_get_pr_head_sha() {
|
||||||
gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid'
|
gh pr view "$PR_NUMBER" --repo "$OWNER/$REPO" --json headRefOid --jq '.headRefOid'
|
||||||
}
|
}
|
||||||
|
|
||||||
github_get_commit_status_json() {
|
github_get_commit_status_json() {
|
||||||
@@ -132,6 +146,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
PR_NUMBER="$2"
|
PR_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-r|--repo)
|
||||||
|
REPO_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-t|--timeout)
|
-t|--timeout)
|
||||||
TIMEOUT_SEC="$2"
|
TIMEOUT_SEC="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -163,10 +181,21 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform > /dev/null
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
REPO_INFO="$REPO_OVERRIDE"
|
||||||
|
PLATFORM=$(detect_platform 2>/dev/null || echo gitea)
|
||||||
|
else
|
||||||
|
detect_platform > /dev/null
|
||||||
|
REPO_INFO=$(get_repo_info)
|
||||||
|
fi
|
||||||
|
|
||||||
OWNER=$(get_repo_owner)
|
if [[ -z "$REPO_INFO" || "$REPO_INFO" == error:* || "$REPO_INFO" != */* ]]; then
|
||||||
REPO=$(get_repo_name)
|
echo "Error: Could not determine repository from git origin. Run from a repo or pass --repo owner/repo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OWNER=${REPO_INFO%%/*}
|
||||||
|
REPO=${REPO_INFO##*/}
|
||||||
START_TS=$(date +%s)
|
START_TS=$(date +%s)
|
||||||
DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
||||||
|
|
||||||
@@ -182,10 +211,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
fi
|
fi
|
||||||
echo "[pr-ci-wait] Platform=github PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
|
echo "[pr-ci-wait] Platform=github PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
HOST=$(get_remote_host) || {
|
HOST=$(get_remote_host 2>/dev/null || echo "git.mosaicstack.dev")
|
||||||
echo "Error: Could not determine remote host." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
TOKEN=$(get_gitea_token "$HOST") || {
|
TOKEN=$(get_gitea_token "$HOST") || {
|
||||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -195,7 +221,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Could not resolve head SHA for PR #$PR_NUMBER." >&2
|
echo "Error: Could not resolve head SHA for PR #$PR_NUMBER." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "[pr-ci-wait] Platform=gitea host=${HOST} PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
|
echo "[pr-ci-wait] Platform=gitea host=${HOST} repo=${OWNER}/${REPO} PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
|
||||||
else
|
else
|
||||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
echo "Closed GitHub PR #$PR_NUMBER"
|
echo "Closed GitHub PR #$PR_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
tea pr comment "$PR_NUMBER" "$COMMENT"
|
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||||
fi
|
fi
|
||||||
tea pr close "$PR_NUMBER"
|
tea pr close "$PR_NUMBER" $(get_gitea_repo_args)
|
||||||
echo "Closed Gitea PR #$PR_NUMBER"
|
echo "Closed Gitea PR #$PR_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -17,6 +17,51 @@ MILESTONE=""
|
|||||||
DRAFT=false
|
DRAFT=false
|
||||||
ISSUE=""
|
ISSUE=""
|
||||||
|
|
||||||
|
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
|
||||||
|
|
||||||
|
gitea_pr_create_api() {
|
||||||
|
local host repo token url payload
|
||||||
|
host=$(get_remote_host) || {
|
||||||
|
echo "Error: could not determine remote host for API fallback" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
repo=$(get_repo_info) || {
|
||||||
|
echo "Error: could not determine repo owner/name for API fallback" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
token=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then
|
||||||
|
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" BASE_BRANCH="$BASE_BRANCH" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"title": os.environ["TITLE"],
|
||||||
|
"head": os.environ["HEAD_BRANCH"],
|
||||||
|
"base": os.environ["BASE_BRANCH"] or "main",
|
||||||
|
}
|
||||||
|
body = os.environ.get("BODY", "")
|
||||||
|
if body:
|
||||||
|
payload["body"] = body
|
||||||
|
print(json.dumps(payload))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
url="https://${host}/api/v1/repos/${repo}/pulls"
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token ${token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload" \
|
||||||
|
"$url"
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [OPTIONS]
|
Usage: $(basename "$0") [OPTIONS]
|
||||||
@@ -118,33 +163,37 @@ PLATFORM=$(detect_platform)
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
CMD="gh pr create --title \"$TITLE\""
|
CMD=(gh pr create --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --body \"$BODY\""
|
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||||
[[ -n "$BASE_BRANCH" ]] && CMD="$CMD --base \"$BASE_BRANCH\""
|
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||||
[[ -n "$HEAD_BRANCH" ]] && CMD="$CMD --head \"$HEAD_BRANCH\""
|
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
||||||
[[ -n "$LABELS" ]] && CMD="$CMD --label \"$LABELS\""
|
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
[[ "$DRAFT" == true ]] && CMD="$CMD --draft"
|
[[ "$DRAFT" == true ]] && CMD+=(--draft)
|
||||||
eval "$CMD"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
# tea pull create syntax
|
# tea pull create syntax. Always pass --repo because tea repo inference
|
||||||
CMD="tea pr create --title \"$TITLE\""
|
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
|
# of eval so markdown backticks/body content are not shell-executed.
|
||||||
[[ -n "$BASE_BRANCH" ]] && CMD="$CMD --base \"$BASE_BRANCH\""
|
REPO_SLUG=$(get_repo_slug)
|
||||||
[[ -n "$HEAD_BRANCH" ]] && CMD="$CMD --head \"$HEAD_BRANCH\""
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "${GITEA_LOGIN:-mosaicstack}")
|
||||||
|
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||||
|
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
||||||
|
|
||||||
# Handle labels for tea
|
# Handle labels for tea
|
||||||
if [[ -n "$LABELS" ]]; then
|
if [[ -n "$LABELS" ]]; then
|
||||||
# tea may use --labels flag
|
# tea may use --labels flag
|
||||||
CMD="$CMD --labels \"$LABELS\""
|
CMD+=(--labels "$LABELS")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Handle milestone for tea
|
# Handle milestone for tea
|
||||||
if [[ -n "$MILESTONE" ]]; then
|
if [[ -n "$MILESTONE" ]]; then
|
||||||
MILESTONE_ID=$(tea milestones list 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
MILESTONE_ID=$(tea milestones list "${REPO_ARGS[@]}" 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
||||||
if [[ -n "$MILESTONE_ID" ]]; then
|
if [[ -n "$MILESTONE_ID" ]]; then
|
||||||
CMD="$CMD --milestone $MILESTONE_ID"
|
CMD+=(--milestone "$MILESTONE_ID")
|
||||||
else
|
else
|
||||||
echo "Warning: Could not find milestone '$MILESTONE', creating without milestone" >&2
|
echo "Warning: Could not find milestone '$MILESTONE', creating without milestone" >&2
|
||||||
fi
|
fi
|
||||||
@@ -155,7 +204,11 @@ case "$PLATFORM" in
|
|||||||
echo "Note: Draft PR may not be supported by your tea version" >&2
|
echo "Note: Draft PR may not be supported by your tea version" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
eval "$CMD"
|
if "${CMD[@]}"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
||||||
|
gitea_pr_create_api
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-diff.sh - Get the diff for a pull request on GitHub or Gitea
|
# pr-diff.sh - Get the diff for a pull request on GitHub or Gitea
|
||||||
# Usage: pr-diff.sh -n <pr_number> [-o <output_file>]
|
# Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [-o <output_file>]
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
OUTPUT_FILE=""
|
OUTPUT_FILE=""
|
||||||
|
REPO_OVERRIDE=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -21,11 +22,16 @@ while [[ $# -gt 0 ]]; do
|
|||||||
OUTPUT_FILE="$2"
|
OUTPUT_FILE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-r|--repo)
|
||||||
|
REPO_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: pr-diff.sh -n <pr_number> [-o <output_file>]"
|
echo "Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [-o <output_file>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
|
echo " -r, --repo Repository slug (default: infer from git origin)"
|
||||||
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -42,31 +48,30 @@ if [[ -z "$PR_NUMBER" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform > /dev/null
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
REPO_INFO="$REPO_OVERRIDE"
|
||||||
|
PLATFORM=$(detect_platform 2>/dev/null || echo gitea)
|
||||||
|
else
|
||||||
|
detect_platform > /dev/null
|
||||||
|
REPO_INFO=$(get_repo_info)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$REPO_INFO" || "$REPO_INFO" == error:* ]]; then
|
||||||
|
echo "Error: Could not determine repository from git origin. Run from a repo or pass --repo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||||
gh pr diff "$PR_NUMBER" > "$OUTPUT_FILE"
|
gh pr diff "$PR_NUMBER" --repo "$REPO_INFO" > "$OUTPUT_FILE"
|
||||||
else
|
else
|
||||||
gh pr diff "$PR_NUMBER"
|
gh pr diff "$PR_NUMBER" --repo "$REPO_INFO"
|
||||||
fi
|
fi
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# tea doesn't have a direct diff command — use the API
|
# tea doesn't have a direct diff command — use the API
|
||||||
OWNER=$(get_repo_owner)
|
HOST=$(get_remote_host 2>/dev/null || echo "git.mosaicstack.dev")
|
||||||
REPO=$(get_repo_name)
|
|
||||||
REMOTE_URL=$(git remote get-url origin 2>/dev/null)
|
|
||||||
|
|
||||||
# Extract host from remote URL
|
DIFF_URL="https://${HOST}/api/v1/repos/${REPO_INFO}/pulls/${PR_NUMBER}.diff"
|
||||||
if [[ "$REMOTE_URL" == https://* ]]; then
|
|
||||||
HOST=$(echo "$REMOTE_URL" | sed -E 's|https://([^/]+)/.*|\1|')
|
|
||||||
elif [[ "$REMOTE_URL" == git@* ]]; then
|
|
||||||
HOST=$(echo "$REMOTE_URL" | sed -E 's|git@([^:]+):.*|\1|')
|
|
||||||
else
|
|
||||||
echo "Error: Cannot determine host from remote URL" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
DIFF_URL="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}.diff"
|
|
||||||
|
|
||||||
GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true)
|
GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-list.sh - List pull requests on Gitea or GitHub
|
# pr-list.sh - List pull requests on Gitea or GitHub
|
||||||
# Usage: pr-list.sh [-s state] [-l label] [-a author]
|
# Usage: pr-list.sh [-r owner/repo] [-s state] [-l label] [-a author]
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ STATE="open"
|
|||||||
LABEL=""
|
LABEL=""
|
||||||
AUTHOR=""
|
AUTHOR=""
|
||||||
LIMIT=100
|
LIMIT=100
|
||||||
|
REPO_OVERRIDE=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -24,12 +25,14 @@ Options:
|
|||||||
-l, --label LABEL Filter by label
|
-l, --label LABEL Filter by label
|
||||||
-a, --author USER Filter by author
|
-a, --author USER Filter by author
|
||||||
-n, --limit N Maximum PRs to show (default: 100)
|
-n, --limit N Maximum PRs to show (default: 100)
|
||||||
|
-r, --repo OWNER/REPO Repository slug (default: infer from git origin)
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") # List open PRs
|
$(basename "$0") # List open PRs
|
||||||
$(basename "$0") -s all # All PRs
|
$(basename "$0") -s all # All PRs
|
||||||
$(basename "$0") -s merged -a username # Merged PRs by user
|
$(basename "$0") -s merged -a username # Merged PRs by user
|
||||||
|
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -53,6 +56,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
LIMIT="$2"
|
LIMIT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-r|--repo)
|
||||||
|
REPO_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
@@ -63,18 +70,30 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
PLATFORM=$(detect_platform)
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
REPO_INFO="$REPO_OVERRIDE"
|
||||||
|
# Explicit --repo is primarily for Gitea wrappers; if a git origin is present,
|
||||||
|
# still honor GitHub detection for cross-platform behavior.
|
||||||
|
PLATFORM=$(detect_platform 2>/dev/null || echo gitea)
|
||||||
|
else
|
||||||
|
PLATFORM=$(detect_platform)
|
||||||
|
REPO_INFO=$(get_repo_info)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$REPO_INFO" || "$REPO_INFO" == error:* ]]; then
|
||||||
|
echo "Error: Could not determine repository from git origin. Run from a repo or pass --repo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
CMD="gh pr list --state $STATE --limit $LIMIT"
|
CMD=(gh pr list --repo "$REPO_INFO" --state "$STATE" --limit "$LIMIT")
|
||||||
[[ -n "$LABEL" ]] && CMD="$CMD --label \"$LABEL\""
|
[[ -n "$LABEL" ]] && CMD+=(--label "$LABEL")
|
||||||
[[ -n "$AUTHOR" ]] && CMD="$CMD --author \"$AUTHOR\""
|
[[ -n "$AUTHOR" ]] && CMD+=(--author "$AUTHOR")
|
||||||
eval "$CMD"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
# tea pr list - note: tea uses 'pulls' subcommand in some versions
|
CMD=(tea pr list --repo "$REPO_INFO" --login "${GITEA_LOGIN:-mosaicstack}" --state "$STATE" --limit "$LIMIT")
|
||||||
CMD="tea pr list --state $STATE --limit $LIMIT"
|
|
||||||
|
|
||||||
# tea filtering may be limited
|
# tea filtering may be limited
|
||||||
if [[ -n "$LABEL" ]]; then
|
if [[ -n "$LABEL" ]]; then
|
||||||
@@ -84,7 +103,7 @@ case "$PLATFORM" in
|
|||||||
echo "Note: Author filtering may require manual review for Gitea" >&2
|
echo "Note: Author filtering may require manual review for Gitea" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
eval "$CMD"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
||||||
|
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
@@ -12,6 +13,7 @@ PR_NUMBER=""
|
|||||||
MERGE_METHOD="squash"
|
MERGE_METHOD="squash"
|
||||||
DELETE_BRANCH=false
|
DELETE_BRANCH=false
|
||||||
SKIP_QUEUE_GUARD=false
|
SKIP_QUEUE_GUARD=false
|
||||||
|
DRY_RUN=false
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -24,6 +26,7 @@ Options:
|
|||||||
-m, --method METHOD Merge method: squash only (default: squash)
|
-m, --method METHOD Merge method: squash only (default: squash)
|
||||||
-d, --delete-branch Delete the head branch after merge
|
-d, --delete-branch Delete the head branch after merge
|
||||||
--skip-queue-guard Skip CI queue guard wait before merge
|
--skip-queue-guard Skip CI queue guard wait before merge
|
||||||
|
--dry-run Run metadata/login preflight without merging
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -54,6 +57,11 @@ while [[ $# -gt 0 ]]; do
|
|||||||
SKIP_QUEUE_GUARD=true
|
SKIP_QUEUE_GUARD=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=true
|
||||||
|
SKIP_QUEUE_GUARD=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
@@ -69,12 +77,18 @@ if [[ -z "$PR_NUMBER" ]]; then
|
|||||||
usage
|
usage
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Error: Invalid PR number '$PR_NUMBER'. PR number must contain digits only." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$MERGE_METHOD" != "squash" ]]; then
|
if [[ "$MERGE_METHOD" != "squash" ]]; then
|
||||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BASE_BRANCH="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||||
|
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -92,21 +106,163 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
|
find_tea_login_for_host() {
|
||||||
|
local host="$1"
|
||||||
|
local logins_json
|
||||||
|
|
||||||
|
command -v tea >/dev/null 2>&1 || return 1
|
||||||
|
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
||||||
|
TEA_LOGINS_JSON="$logins_json" python3 - "$host" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
host = sys.argv[1]
|
||||||
|
try:
|
||||||
|
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
for login in logins if isinstance(logins, list) else []:
|
||||||
|
url = str(login.get("url") or login.get("URL") or "")
|
||||||
|
name = str(login.get("name") or login.get("Name") or "")
|
||||||
|
if url.rstrip("/").endswith(host) and name:
|
||||||
|
print(name)
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
is_known_tea_empty_identity_failure() {
|
||||||
|
local error_file="$1"
|
||||||
|
|
||||||
|
python3 - "$error_file" <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
||||||
|
error = handle.read()
|
||||||
|
|
||||||
|
known_empty_identity = re.search(
|
||||||
|
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
||||||
|
error,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
raise SystemExit(0 if known_empty_identity else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_gitea_with_api() {
|
||||||
|
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||||
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||||
|
mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}"
|
||||||
|
body_file=$(mktemp "${AGENT_WORK_ROOT:-/home/hermes/agent-work}/pr-merge-api-response.XXXXXX")
|
||||||
|
payload='{"Do":"squash"}'
|
||||||
|
|
||||||
|
token=$(get_gitea_token "$host" || true)
|
||||||
|
if [[ -n "$token" ]]; then
|
||||||
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token $token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" \
|
||||||
|
"$api_url" || true)
|
||||||
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
|
rm -f "$body_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
basic_auth=$(get_gitea_basic_auth "$host" || true)
|
||||||
|
if [[ -n "$basic_auth" ]]; then
|
||||||
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||||
|
-X POST \
|
||||||
|
-u "$basic_auth" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" \
|
||||||
|
"$api_url" || true)
|
||||||
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
|
rm -f "$body_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
code, path = sys.argv[1], sys.argv[2]
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||||
|
raw = handle.read(500)
|
||||||
|
data = json.loads(raw) if raw else {}
|
||||||
|
message = data.get("message") or data.get("error") or raw or "empty response"
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
|
||||||
|
except Exception:
|
||||||
|
message = "unreadable response"
|
||||||
|
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
|
||||||
|
PY
|
||||||
|
rm -f "$body_file"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
if [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
|
HOST=$(get_remote_host) || {
|
||||||
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}"
|
||||||
|
if [[ -n "$TEA_LOGIN" ]]; then
|
||||||
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
||||||
|
else
|
||||||
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
CMD="gh pr merge $PR_NUMBER --squash"
|
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
||||||
[[ "$DELETE_BRANCH" == true ]] && CMD="$CMD --delete-branch"
|
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||||
eval "$CMD"
|
"${cmd[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
CMD="tea pr merge $PR_NUMBER --style squash --repo $OWNER/$REPO --login ${GITEA_LOGIN:-mosaicstack}"
|
HOST=$(get_remote_host) || {
|
||||||
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}"
|
||||||
|
|
||||||
|
if [[ -n "$TEA_LOGIN" ]]; then
|
||||||
|
mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}"
|
||||||
|
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-/home/hermes/agent-work}/pr-merge-tea-error.XXXXXX")
|
||||||
|
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
||||||
|
rm -f "$TEA_ERROR_FILE"
|
||||||
|
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
||||||
|
cat "$TEA_ERROR_FILE" >&2
|
||||||
|
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
||||||
|
rm -f "$TEA_ERROR_FILE"
|
||||||
|
merge_gitea_with_api "$HOST"
|
||||||
|
else
|
||||||
|
cat "$TEA_ERROR_FILE" >&2
|
||||||
|
rm -f "$TEA_ERROR_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
||||||
|
merge_gitea_with_api "$HOST"
|
||||||
|
fi
|
||||||
|
|
||||||
# Delete branch after merge if requested
|
# Delete branch after merge if requested
|
||||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
if [[ "$DELETE_BRANCH" == true ]]; then
|
||||||
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
eval "$CMD"
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
# pr-metadata.sh - Get PR metadata as JSON on GitHub or Gitea
|
# pr-metadata.sh - Get PR metadata as JSON on GitHub or Gitea
|
||||||
# Usage: pr-metadata.sh -n <pr_number> [-o <output_file>]
|
# Usage: pr-metadata.sh -n <pr_number> [-o <output_file>]
|
||||||
|
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -31,7 +32,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
echo "Unknown option: $1" >&2
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -42,56 +43,168 @@ if [[ -z "$PR_NUMBER" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
write_metadata() {
|
||||||
|
local metadata="$1"
|
||||||
|
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||||
|
printf '%s\n' "$metadata" > "$OUTPUT_FILE"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$metadata"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_gitea_pull() {
|
||||||
|
local api_url="$1"
|
||||||
|
local token basic_auth raw_code body_file http_code
|
||||||
|
body_file=$(mktemp)
|
||||||
|
|
||||||
|
token=$(get_gitea_token "$HOST" || true)
|
||||||
|
if [[ -n "$token" ]]; then
|
||||||
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "Authorization: token $token" "$api_url" || true)
|
||||||
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
|
cat "$body_file"
|
||||||
|
rm -f "$body_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
http_code="$raw_code"
|
||||||
|
fi
|
||||||
|
|
||||||
|
basic_auth=$(get_gitea_basic_auth "$HOST" || true)
|
||||||
|
if [[ -n "$basic_auth" ]]; then
|
||||||
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" "$api_url" || true)
|
||||||
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
|
cat "$body_file"
|
||||||
|
rm -f "$body_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
http_code="$raw_code"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${http_code:-}" ]]; then
|
||||||
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" "$api_url" || true)
|
||||||
|
http_code="$raw_code"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$http_code" "$body_file" <<'PY' >&2
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
code, path = sys.argv[1], sys.argv[2]
|
||||||
|
try:
|
||||||
|
data = json.load(open(path, encoding="utf-8"))
|
||||||
|
message = data.get("message") or data.get("error") or "unknown API error"
|
||||||
|
except Exception:
|
||||||
|
message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response"
|
||||||
|
print(f"Error: Gitea pull request API request failed with HTTP {code}: {message}")
|
||||||
|
PY
|
||||||
|
rm -f "$body_file"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
detect_platform > /dev/null
|
detect_platform > /dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||||
|
write_metadata "$METADATA"
|
||||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
|
||||||
echo "$METADATA" > "$OUTPUT_FILE"
|
|
||||||
else
|
|
||||||
echo "$METADATA"
|
|
||||||
fi
|
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
REMOTE_URL=$(git remote get-url origin 2>/dev/null)
|
HOST=$(get_remote_host) || {
|
||||||
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
# Extract host from remote URL
|
|
||||||
if [[ "$REMOTE_URL" == https://* ]]; then
|
|
||||||
HOST=$(echo "$REMOTE_URL" | sed -E 's|https://([^/]+)/.*|\1|')
|
|
||||||
elif [[ "$REMOTE_URL" == git@* ]]; then
|
|
||||||
HOST=$(echo "$REMOTE_URL" | sed -E 's|git@([^:]+):.*|\1|')
|
|
||||||
else
|
|
||||||
echo "Error: Cannot determine host from remote URL" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
}
|
||||||
|
|
||||||
API_URL="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
API_URL="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
|
||||||
|
if [[ -n "${MOSAIC_GITEA_PR_METADATA_RAW_FILE:-}" ]]; then
|
||||||
GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true)
|
RAW=$(cat "$MOSAIC_GITEA_PR_METADATA_RAW_FILE")
|
||||||
|
|
||||||
if [[ -n "$GITEA_API_TOKEN" ]]; then
|
|
||||||
RAW=$(curl -sS -H "Authorization: token $GITEA_API_TOKEN" "$API_URL")
|
|
||||||
else
|
else
|
||||||
RAW=$(curl -sS "$API_URL")
|
RAW=$(curl_gitea_pull "$API_URL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Normalize Gitea response to match our expected schema
|
# Normalize Gitea response to match GitHub's expected metadata schema.
|
||||||
METADATA=$(echo "$RAW" | python3 -c "
|
METADATA=$(printf '%s' "$RAW" | python3 -c "
|
||||||
import json, sys
|
import json
|
||||||
data = json.load(sys.stdin)
|
import sys
|
||||||
|
|
||||||
|
def first_non_empty(*values):
|
||||||
|
for value in values:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def nested(data, *keys):
|
||||||
|
current = data
|
||||||
|
for key in keys:
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return None
|
||||||
|
current = current.get(key)
|
||||||
|
return current
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
print(f'Error: Gitea API returned non-JSON response: {exc}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
print('Error: Gitea API returned an unexpected non-object response', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if data.get('message') and not data.get('number'):
|
||||||
|
print(f\"Error: Gitea API error: {data.get('message')}\", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
head_ref = first_non_empty(
|
||||||
|
nested(data, 'head', 'ref'),
|
||||||
|
nested(data, 'head', 'name'),
|
||||||
|
nested(data, 'head', 'branch'),
|
||||||
|
data.get('head_branch'),
|
||||||
|
data.get('head_ref'),
|
||||||
|
nested(data, 'head', 'label'),
|
||||||
|
data.get('head_label'),
|
||||||
|
)
|
||||||
|
if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
||||||
|
head_ref = first_non_empty(
|
||||||
|
nested(data, 'head', 'label'),
|
||||||
|
data.get('head_label'),
|
||||||
|
nested(data, 'head', 'name'),
|
||||||
|
nested(data, 'head', 'branch'),
|
||||||
|
data.get('head_branch'),
|
||||||
|
data.get('head_ref'),
|
||||||
|
head_ref,
|
||||||
|
)
|
||||||
|
base_ref = first_non_empty(
|
||||||
|
nested(data, 'base', 'ref'),
|
||||||
|
nested(data, 'base', 'name'),
|
||||||
|
nested(data, 'base', 'branch'),
|
||||||
|
data.get('base_branch'),
|
||||||
|
data.get('base_ref'),
|
||||||
|
data.get('base_label'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not head_ref or not base_ref:
|
||||||
|
available = ', '.join(sorted(data.keys()))
|
||||||
|
print(
|
||||||
|
'Error: Unable to resolve non-empty Gitea PR head/base refs '
|
||||||
|
f'(headRefName={head_ref!r}, baseRefName={base_ref!r}; keys={available})',
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
normalized = {
|
normalized = {
|
||||||
'number': data.get('number'),
|
'number': data.get('number'),
|
||||||
'title': data.get('title'),
|
'title': data.get('title'),
|
||||||
'body': data.get('body', ''),
|
'body': data.get('body', ''),
|
||||||
'state': data.get('state'),
|
'state': data.get('state'),
|
||||||
'author': data.get('user', {}).get('login', ''),
|
'author': nested(data, 'user', 'login') or '',
|
||||||
'headRefName': data.get('head', {}).get('ref', ''),
|
'headRefName': head_ref,
|
||||||
'baseRefName': data.get('base', {}).get('ref', ''),
|
'baseRefName': base_ref,
|
||||||
'labels': [l.get('name', '') for l in data.get('labels', [])],
|
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||||
'assignees': [a.get('login', '') for a in data.get('assignees', [])],
|
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||||
'milestone': data.get('milestone', {}).get('title', '') if data.get('milestone') else '',
|
'milestone': nested(data, 'milestone', 'title') or '',
|
||||||
'createdAt': data.get('created_at', ''),
|
'createdAt': data.get('created_at', ''),
|
||||||
'updatedAt': data.get('updated_at', ''),
|
'updatedAt': data.get('updated_at', ''),
|
||||||
'url': data.get('html_url', ''),
|
'url': data.get('html_url', ''),
|
||||||
@@ -102,11 +215,7 @@ normalized = {
|
|||||||
json.dump(normalized, sys.stdout, indent=2)
|
json.dump(normalized, sys.stdout, indent=2)
|
||||||
")
|
")
|
||||||
|
|
||||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
write_metadata "$METADATA"
|
||||||
echo "$METADATA" > "$OUTPUT_FILE"
|
|
||||||
else
|
|
||||||
echo "$METADATA"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform" >&2
|
echo "Error: Unknown platform" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
tea pr approve "$PR_NUMBER" ${COMMENT:+--comment "$COMMENT"}
|
tea pr approve "$PR_NUMBER" $(get_gitea_repo_args) ${COMMENT:+--comment "$COMMENT"}
|
||||||
echo "Approved Gitea PR #$PR_NUMBER"
|
echo "Approved Gitea PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
request-changes)
|
request-changes)
|
||||||
@@ -93,7 +93,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required for request-changes"
|
echo "Error: Comment required for request-changes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tea pr reject "$PR_NUMBER" --comment "$COMMENT"
|
tea pr reject "$PR_NUMBER" $(get_gitea_repo_args) --comment "$COMMENT"
|
||||||
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
comment)
|
comment)
|
||||||
@@ -101,7 +101,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tea pr comment "$PR_NUMBER" "$COMMENT"
|
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||||
echo "Added comment to Gitea PR #$PR_NUMBER"
|
echo "Added comment to Gitea PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-view.sh - View pull request details on GitHub or Gitea
|
# pr-view.sh - View pull request details on GitHub or Gitea
|
||||||
# Usage: pr-view.sh -n <pr_number>
|
# Usage: pr-view.sh -n <pr_number> [-r owner/repo]
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
|
REPO_OVERRIDE=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -16,11 +17,16 @@ while [[ $# -gt 0 ]]; do
|
|||||||
PR_NUMBER="$2"
|
PR_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-r|--repo)
|
||||||
|
REPO_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: pr-view.sh -n <pr_number>"
|
echo "Usage: pr-view.sh -n <pr_number> [-r owner/repo]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
|
echo " -r, --repo Repository slug (default: infer from git origin)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -36,12 +42,23 @@ if [[ -z "$PR_NUMBER" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
REPO_INFO="$REPO_OVERRIDE"
|
||||||
|
PLATFORM=$(detect_platform 2>/dev/null || echo gitea)
|
||||||
|
else
|
||||||
|
PLATFORM=$(detect_platform)
|
||||||
|
REPO_INFO=$(get_repo_info)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$REPO_INFO" || "$REPO_INFO" == error:* ]]; then
|
||||||
|
echo "Error: Could not determine repository from git origin. Run from a repo or pass --repo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh pr view "$PR_NUMBER"
|
gh pr view "$PR_NUMBER" --repo "$REPO_INFO"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
tea pr "$PR_NUMBER"
|
tea pr "$PR_NUMBER" --repo "$REPO_INFO" --login "${GITEA_LOGIN:-mosaicstack}"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
254
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh
Executable file
254
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh
Executable file
@@ -0,0 +1,254 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_ROOT="${AGENT_WORK_ROOT:-/home/hermes/agent-work}"
|
||||||
|
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
|
||||||
|
MOCK_BIN="$SANDBOX/bin"
|
||||||
|
REPO_DIR="$SANDBOX/repo"
|
||||||
|
LOG_FILE="$SANDBOX/mock.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$SANDBOX"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$MOCK_BIN" "$REPO_DIR"
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
|
||||||
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||||
|
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||||
|
if [[ "$*" == *"pr merge"* ]]; then
|
||||||
|
echo 'user does not exist [uid: 0, name: ]' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/tea"
|
||||||
|
|
||||||
|
cat > "$MOCK_BIN/curl" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'curl %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||||
|
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||||
|
args=" $* "
|
||||||
|
out_file=""
|
||||||
|
write_code=false
|
||||||
|
post_data=""
|
||||||
|
prev=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [[ "$prev" == "-o" ]]; then
|
||||||
|
out_file="$arg"
|
||||||
|
prev=""
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$prev" == "-d" ]]; then
|
||||||
|
post_data="$arg"
|
||||||
|
prev=""
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$arg" == "-o" ]]; then
|
||||||
|
prev="-o"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$arg" == "-d" ]]; then
|
||||||
|
prev="-d"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$arg" == "-w" ]]; then
|
||||||
|
write_code=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
emit_response() {
|
||||||
|
local body="$1"
|
||||||
|
if [[ -n "$out_file" ]]; then
|
||||||
|
printf '%s' "$body" > "$out_file"
|
||||||
|
else
|
||||||
|
printf '%s' "$body"
|
||||||
|
fi
|
||||||
|
if [[ "$write_code" == true ]]; then
|
||||||
|
printf '200'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||||
|
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||||
|
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
||||||
|
echo "unexpected merge payload: $post_data" >&2
|
||||||
|
exit 96
|
||||||
|
fi
|
||||||
|
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "unexpected curl invocation: $*" >&2
|
||||||
|
exit 97
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/curl"
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
git init -q
|
||||||
|
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
export PATH="$MOCK_BIN:$PATH"
|
||||||
|
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
||||||
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
|
||||||
|
OUTPUT="$SANDBOX/output.log"
|
||||||
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
|
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
||||||
|
echo "--- output ---" >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
|
echo "--- mock log ---" >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
||||||
|
echo "Expected authenticated Gitea merge API endpoint to be called." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q 'redacted-test-token' "$OUTPUT"; then
|
||||||
|
echo "Token leaked to pr-merge.sh output." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||||
|
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||||
|
if [[ "$*" == *"pr merge"* ]]; then
|
||||||
|
echo 'tea network timeout' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/tea"
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
|
echo "Expected arbitrary tea failure to remain blocking." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
||||||
|
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q 'tea network timeout' "$OUTPUT"; then
|
||||||
|
echo "Expected arbitrary tea error to be preserved in output." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||||
|
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||||
|
if [[ "$*" == *"login list"* ]]; then
|
||||||
|
echo '[]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "$*" == *"pr merge"* ]]; then
|
||||||
|
echo 'tea merge should not run without a configured host login' >&2
|
||||||
|
exit 99
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/tea"
|
||||||
|
unset GITEA_LOGIN
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
|
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
||||||
|
echo "Expected missing tea login path to call Gitea API merge endpoint." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SENTINEL="$SANDBOX/injected-sentinel"
|
||||||
|
INJECTION="123; touch $SENTINEL #"
|
||||||
|
|
||||||
|
cat > "$MOCK_BIN/gh" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'gh %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||||
|
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||||
|
if [[ "$*" == *"pr view"* ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
{"number":123,"title":"mock","baseRefName":"main","headRefName":"feature/mock"}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "$*" == *"pr merge"* ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "unexpected gh invocation: $*" >&2
|
||||||
|
exit 98
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/gh"
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
rm -f "$SENTINEL"
|
||||||
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
|
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -e "$SENTINEL" ]]; then
|
||||||
|
echo "GitHub metacharacter PR number executed injected shell command." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -s "$LOG_FILE" ]]; then
|
||||||
|
echo "GitHub metacharacter PR number should be rejected before gh calls." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
||||||
|
echo "Expected invalid PR number error for GitHub metacharacter input." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
rm -f "$SENTINEL"
|
||||||
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||||
|
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -e "$SENTINEL" ]]; then
|
||||||
|
echo "Gitea metacharacter PR number executed injected shell command." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -s "$LOG_FILE" ]]; then
|
||||||
|
echo "Gitea metacharacter PR number should be rejected before tea/curl calls." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
||||||
|
echo "Expected invalid PR number error for Gitea metacharacter input." >&2
|
||||||
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pr-merge.sh Gitea fallback regression passed"
|
||||||
87
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh
Executable file
87
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for Gitea PR metadata normalization.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
FIXTURE_DIR="$WORK_DIR/fixtures"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$REPO_DIR" "$FIXTURE_DIR"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
|
||||||
|
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"number": 1905,
|
||||||
|
"title": "Smoke gate fix",
|
||||||
|
"state": "open",
|
||||||
|
"user": {"login": "edith"},
|
||||||
|
"head": {"ref": "edith/t_39ce717c-authentik-smoke-gate"},
|
||||||
|
"base": {"ref": "main"},
|
||||||
|
"labels": [{"name": "ci"}],
|
||||||
|
"assignees": [{"login": "edith"}],
|
||||||
|
"html_url": "https://git.uscllc.com/USC/uconnect/pulls/1905"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$FIXTURE_DIR/gitea-fallback.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"number": 1908,
|
||||||
|
"title": "Fallback branch fields",
|
||||||
|
"state": "open",
|
||||||
|
"user": {"login": "edith"},
|
||||||
|
"head_branch": "fix/fallback-head",
|
||||||
|
"base_branch": "main",
|
||||||
|
"html_url": "https://git.uscllc.com/USC/uconnect/pulls/1908"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$FIXTURE_DIR/gitea-refs-pull-label.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"number": 1908,
|
||||||
|
"title": "Closed merged PR with synthetic pull ref",
|
||||||
|
"state": "closed",
|
||||||
|
"user": {"login": "edith"},
|
||||||
|
"head": {"ref": "refs/pull/1908/head", "label": "fix/t_23fa9e1d-portal-health-backend"},
|
||||||
|
"base": {"ref": "main"},
|
||||||
|
"html_url": "https://git.uscllc.com/USC/uconnect/pulls/1908"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$FIXTURE_DIR/gitea-error.json" <<'JSON'
|
||||||
|
{"message": "user does not exist [uid: 0, name: ]", "url": "https://git.uscllc.com/api/swagger"}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
run_case() {
|
||||||
|
local fixture="$1" expected_number="$2" expected_head="$3"
|
||||||
|
local output
|
||||||
|
output=$(cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
||||||
|
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
data = json.loads(os.environ["PR_METADATA_OUTPUT"])
|
||||||
|
expected_number = int(sys.argv[1])
|
||||||
|
expected_head = sys.argv[2]
|
||||||
|
assert data["number"] == expected_number, data
|
||||||
|
assert data["baseRefName"] == "main", data
|
||||||
|
assert data["headRefName"] == expected_head, data
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
run_case "$FIXTURE_DIR/gitea-standard.json" 1905 edith/t_39ce717c-authentik-smoke-gate
|
||||||
|
run_case "$FIXTURE_DIR/gitea-fallback.json" 1908 fix/fallback-head
|
||||||
|
run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-health-backend
|
||||||
|
|
||||||
|
if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then
|
||||||
|
echo "Expected API error fixture to fail" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q "Gitea API error" "$WORK_DIR/error.log"
|
||||||
|
|
||||||
|
echo "Gitea PR metadata regression harness passed"
|
||||||
@@ -50,7 +50,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
|
|||||||
|
|
||||||
response=$(curl -sk -w "\n%{http_code}" \
|
response=$(curl -sk -w "\n%{http_code}" \
|
||||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||||
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?per_page=${LIMIT}")
|
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}")
|
||||||
|
|
||||||
http_code=$(echo "$response" | tail -n1)
|
http_code=$(echo "$response" | tail -n1)
|
||||||
body=$(echo "$response" | sed '$d')
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ _wp_fetch() {
|
|||||||
|
|
||||||
if [[ -z "$NUMBER" ]]; then
|
if [[ -z "$NUMBER" ]]; then
|
||||||
# Get latest pipeline number from list, then fetch full detail
|
# Get latest pipeline number from list, then fetch full detail
|
||||||
list_body=$(_wp_fetch "${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?per_page=1") || exit 1
|
list_body=$(_wp_fetch "${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=1") || exit 1
|
||||||
NUMBER=$(echo "$list_body" | jq -r '.[0].number // empty')
|
NUMBER=$(echo "$list_body" | jq -r '.[0].number // empty')
|
||||||
if [[ -z "$NUMBER" ]]; then
|
if [[ -z "$NUMBER" ]]; then
|
||||||
echo "Error: No pipelines found" >&2
|
echo "Error: No pipelines found" >&2
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { users, teams, teamMembers, conversations, messages } from '@mosaicstack/db';
|
import {
|
||||||
import { createPgliteDbWithVector, runPgliteMigrations } from './test-utils/pglite-with-vector.js';
|
users,
|
||||||
|
teams,
|
||||||
|
teamMembers,
|
||||||
|
conversations,
|
||||||
|
messages,
|
||||||
|
createPgliteDb,
|
||||||
|
runPgliteMigrations,
|
||||||
|
} from '@mosaicstack/db';
|
||||||
|
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { afterAll, describe, expect, it } from 'vitest';
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
@@ -102,11 +109,8 @@ describe.skipIf(!run)('migrate-tier — PGlite → federated PG', () => {
|
|||||||
/* ---- 1. Create a temp PGlite db ---------------------------------- */
|
/* ---- 1. Create a temp PGlite db ---------------------------------- */
|
||||||
|
|
||||||
pgliteDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fed-m1-08-'));
|
pgliteDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fed-m1-08-'));
|
||||||
const handle = createPgliteDbWithVector(pgliteDataDir);
|
const handle = createPgliteDb(pgliteDataDir);
|
||||||
|
await runPgliteMigrations(handle);
|
||||||
// Run Drizzle migrations against PGlite.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
await runPgliteMigrations(handle.db as any);
|
|
||||||
|
|
||||||
/* ---- 2. Seed representative data --------------------------------- */
|
/* ---- 2. Seed representative data --------------------------------- */
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test-only helpers for creating a PGlite database with the pgvector extension
|
|
||||||
* and running Drizzle migrations against it.
|
|
||||||
*
|
|
||||||
* These are intentionally NOT exported from @mosaicstack/db to avoid pulling
|
|
||||||
* the WASM vector bundle into the public API surface.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import { dirname, resolve } from 'node:path';
|
|
||||||
|
|
||||||
import { PGlite } from '@electric-sql/pglite';
|
|
||||||
import { vector } from '@electric-sql/pglite/vector';
|
|
||||||
import { drizzle } from 'drizzle-orm/pglite';
|
|
||||||
import { migrate as migratePglite } from 'drizzle-orm/pglite/migrator';
|
|
||||||
import type { PgliteDatabase } from 'drizzle-orm/pglite';
|
|
||||||
import * as schema from '@mosaicstack/db';
|
|
||||||
import type { DbHandle } from '@mosaicstack/db';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a PGlite DB handle with the pgvector extension loaded.
|
|
||||||
* Required for running Drizzle migrations that include `CREATE EXTENSION vector`.
|
|
||||||
*/
|
|
||||||
export function createPgliteDbWithVector(dataDir: string): DbHandle {
|
|
||||||
const client = new PGlite(dataDir, { extensions: { vector } });
|
|
||||||
const db = drizzle(client, { schema });
|
|
||||||
return {
|
|
||||||
db: db as unknown as DbHandle['db'],
|
|
||||||
close: async () => {
|
|
||||||
await client.close();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Drizzle migrations against an already-open PGlite database handle.
|
|
||||||
* Resolves the migrations folder from @mosaicstack/db's installed location.
|
|
||||||
*
|
|
||||||
* @param db A PgliteDatabase instance (from drizzle-orm/pglite).
|
|
||||||
*/
|
|
||||||
export async function runPgliteMigrations(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
db: PgliteDatabase<any>,
|
|
||||||
): Promise<void> {
|
|
||||||
// Resolve @mosaicstack/db package root to locate its drizzle migrations folder.
|
|
||||||
const _require = createRequire(import.meta.url);
|
|
||||||
const dbPkgMain = _require.resolve('@mosaicstack/db');
|
|
||||||
// dbPkgMain → …/packages/db/dist/index.js → dirname = dist/
|
|
||||||
// go up one level from dist/ to find the sibling drizzle/ folder
|
|
||||||
const migrationsFolder = resolve(dirname(dbPkgMain), '../drizzle');
|
|
||||||
await migratePglite(db, { migrationsFolder });
|
|
||||||
}
|
|
||||||
64
pnpm-lock.yaml
generated
64
pnpm-lock.yaml
generated
@@ -179,6 +179,9 @@ importers:
|
|||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
|
undici:
|
||||||
|
specifier: ^7.24.6
|
||||||
|
version: 7.24.6
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
@@ -713,10 +716,10 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-agent-core':
|
'@mariozechner/pi-agent-core':
|
||||||
specifier: ^0.63.1
|
specifier: ^0.63.1
|
||||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
'@mariozechner/pi-ai':
|
'@mariozechner/pi-ai':
|
||||||
specifier: ^0.63.1
|
specifier: ^0.63.1
|
||||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: ^0.34.41
|
specifier: ^0.34.41
|
||||||
version: 0.34.48
|
version: 0.34.48
|
||||||
@@ -6993,10 +6996,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
||||||
engines: {node: '>=18.17'}
|
engines: {node: '>=18.17'}
|
||||||
|
|
||||||
undici@7.24.3:
|
|
||||||
resolution: {integrity: sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==}
|
|
||||||
engines: {node: '>=20.18.1'}
|
|
||||||
|
|
||||||
undici@7.24.6:
|
undici@7.24.6:
|
||||||
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
|
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
@@ -7329,12 +7328,6 @@ snapshots:
|
|||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
'@anthropic-ai/sdk@0.73.0(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
json-schema-to-ts: 3.1.1
|
|
||||||
optionalDependencies:
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
|
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
json-schema-to-ts: 3.1.1
|
json-schema-to-ts: 3.1.1
|
||||||
@@ -8676,18 +8669,6 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@modelcontextprotocol/sdk'
|
|
||||||
- aws-crt
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
- ws
|
|
||||||
- zod
|
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
@@ -8736,30 +8717,6 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
'@anthropic-ai/sdk': 0.73.0(zod@3.25.76)
|
|
||||||
'@aws-sdk/client-bedrock-runtime': 3.1008.0
|
|
||||||
'@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
|
|
||||||
'@mistralai/mistralai': 1.14.1
|
|
||||||
'@sinclair/typebox': 0.34.48
|
|
||||||
ajv: 8.18.0
|
|
||||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
|
||||||
chalk: 5.6.2
|
|
||||||
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
|
|
||||||
partial-json: 0.1.7
|
|
||||||
proxy-agent: 6.5.0
|
|
||||||
undici: 7.24.3
|
|
||||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@modelcontextprotocol/sdk'
|
|
||||||
- aws-crt
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
- ws
|
|
||||||
- zod
|
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||||
@@ -8773,7 +8730,7 @@ snapshots:
|
|||||||
openai: 6.26.0(ws@8.20.0)(zod@4.3.6)
|
openai: 6.26.0(ws@8.20.0)(zod@4.3.6)
|
||||||
partial-json: 0.1.7
|
partial-json: 0.1.7
|
||||||
proxy-agent: 6.5.0
|
proxy-agent: 6.5.0
|
||||||
undici: 7.24.3
|
undici: 7.24.6
|
||||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@modelcontextprotocol/sdk'
|
- '@modelcontextprotocol/sdk'
|
||||||
@@ -12632,7 +12589,7 @@ snapshots:
|
|||||||
saxes: 6.0.0
|
saxes: 6.0.0
|
||||||
symbol-tree: 3.2.4
|
symbol-tree: 3.2.4
|
||||||
tough-cookie: 6.0.1
|
tough-cookie: 6.0.1
|
||||||
undici: 7.24.3
|
undici: 7.24.6
|
||||||
w3c-xmlserializer: 5.0.0
|
w3c-xmlserializer: 5.0.0
|
||||||
webidl-conversions: 8.0.1
|
webidl-conversions: 8.0.1
|
||||||
whatwg-mimetype: 5.0.0
|
whatwg-mimetype: 5.0.0
|
||||||
@@ -13352,11 +13309,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-function: 5.0.1
|
mimic-function: 5.0.1
|
||||||
|
|
||||||
openai@6.26.0(ws@8.20.0)(zod@3.25.76):
|
|
||||||
optionalDependencies:
|
|
||||||
ws: 8.20.0
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
openai@6.26.0(ws@8.20.0)(zod@4.3.6):
|
openai@6.26.0(ws@8.20.0)(zod@4.3.6):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ws: 8.20.0
|
ws: 8.20.0
|
||||||
@@ -14488,8 +14440,6 @@ snapshots:
|
|||||||
|
|
||||||
undici@6.21.3: {}
|
undici@6.21.3: {}
|
||||||
|
|
||||||
undici@7.24.3: {}
|
|
||||||
|
|
||||||
undici@7.24.6: {}
|
undici@7.24.6: {}
|
||||||
|
|
||||||
unhomoglyph@1.0.6: {}
|
unhomoglyph@1.0.6: {}
|
||||||
|
|||||||
125
scratchpads/fix-db-bootstrap-migrations.md
Normal file
125
scratchpads/fix-db-bootstrap-migrations.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# fix(db): bootstrap migrations on local-tier gateway startup
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Fresh `mosaic gateway install` (npm-installed) leaves the gateway DB schema empty:
|
||||||
|
|
||||||
|
```
|
||||||
|
relation "users" does not exist
|
||||||
|
```
|
||||||
|
|
||||||
|
Sign-in 500s, `auth users create` says "Not signed in", `admin/bootstrap setup`
|
||||||
|
also fails — every entry point queries `users` before doing anything else.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This PR fixes the **local (PGlite) tier** end-to-end. The postgres-tier path
|
||||||
|
has additional pre-existing bugs (see "Known issues, out of scope" below) and
|
||||||
|
needs a separate change with real Postgres validation.
|
||||||
|
|
||||||
|
## Root causes addressed (5 stacked bugs on the local-tier path)
|
||||||
|
|
||||||
|
1. **`packages/db/package.json` `files: ["dist"]`** — the `drizzle/` SQL
|
||||||
|
migrations folder is excluded from the published tarball. Even if a
|
||||||
|
migrate runner existed, it would have nothing to apply.
|
||||||
|
|
||||||
|
2. **`packages/db/src/migrate.ts`** only supports `postgres-js`. Local-tier
|
||||||
|
gateways use embedded PGlite, which can't be reached over a postgres wire
|
||||||
|
protocol — so `runMigrations()` is unusable for the local tier.
|
||||||
|
|
||||||
|
3. **`apps/gateway/src/database/database.module.ts`** never invokes
|
||||||
|
migrations at startup. The module creates the DB handle and storage
|
||||||
|
adapter, but no consumer calls `.migrate()` on either. `mosaic storage
|
||||||
|
migrate` CLI even claims "pglite runs schema setup automatically on first
|
||||||
|
connection via `adapter.migrate()`" — but `adapter.migrate()` is only
|
||||||
|
called by tests, never at runtime.
|
||||||
|
|
||||||
|
4. **`createPgliteDb` does not load the pgvector extension.** Migration 0001
|
||||||
|
declares `CREATE EXTENSION IF NOT EXISTS vector;` for the
|
||||||
|
`insights.embedding` column. Bare PGlite has no pgvector — the migration
|
||||||
|
fails on extension control file lookup.
|
||||||
|
|
||||||
|
5. **Drizzle's PG migrator wraps every migration in one outer transaction.**
|
||||||
|
Migration 0009 does `ALTER TYPE grant_status ADD VALUE 'pending'` and then
|
||||||
|
`ALTER TABLE federation_grants ALTER COLUMN status SET DEFAULT 'pending'`.
|
||||||
|
Postgres' `check_safe_enum_use` rejects the second statement because the
|
||||||
|
new enum value isn't committed yet. Splitting the migration into two
|
||||||
|
files doesn't help — drizzle batches all migrations into one outer tx.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
- `packages/db/package.json` — ship `drizzle/` in `files`.
|
||||||
|
- `packages/db/src/client-pglite.ts` — load `@electric-sql/pglite/vector`.
|
||||||
|
- `packages/db/src/migrate.ts` — add `runPgliteMigrations(handle)`. Walks the
|
||||||
|
Drizzle journal and runs each statement-breakpoint chunk through PGlite's
|
||||||
|
`client.exec()` (Simple Query protocol → autocommit per statement). Writes
|
||||||
|
to the standard `drizzle.__drizzle_migrations` ledger so the result is
|
||||||
|
interoperable with `runMigrations()` on a postgres-backed deployment.
|
||||||
|
Per-statement try/catch surfaces which statement of which migration failed
|
||||||
|
and the ledger row is only written on full success.
|
||||||
|
- `packages/db/src/index.ts` — re-export.
|
||||||
|
- `apps/gateway/src/database/database.module.ts` — implement `OnModuleInit`:
|
||||||
|
- Local tier → `runPgliteMigrations(handle)`, then `storageAdapter.migrate()`
|
||||||
|
(the local storage adapter has its own kv tables in a separate PGlite dir).
|
||||||
|
- Postgres tier → `storageAdapter.migrate()` only, since
|
||||||
|
`PostgresAdapter.migrate()` already calls `runMigrations(url)` against
|
||||||
|
the same DATABASE_URL — we deliberately don't double-call.
|
||||||
|
|
||||||
|
NestJS awaits `onModuleInit` before `app.listen()`, so DB-dependent modules
|
||||||
|
see a populated schema before any HTTP traffic is accepted.
|
||||||
|
|
||||||
|
- `packages/storage/src/test-utils/pglite-with-vector.ts` — **deleted**.
|
||||||
|
The "intentionally not exported" rationale is moot now that migration 0001
|
||||||
|
forces pgvector load anyway. `migrate-tier.integration.test.ts` switched
|
||||||
|
to `createPgliteDb` + `runPgliteMigrations` from `@mosaicstack/db`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`packages/db/src/migrate.test.ts`:
|
||||||
|
|
||||||
|
- Verifies `runPgliteMigrations` creates the BetterAuth tables (the original
|
||||||
|
failure mode).
|
||||||
|
- Idempotence (transitively re-runs migration 0009).
|
||||||
|
- Partial-failure: pre-creates a conflicting `users` table, asserts the
|
||||||
|
thrown error includes statement context (`hash=… statement #N failed`)
|
||||||
|
and that no ledger row was written.
|
||||||
|
|
||||||
|
## QA evidence
|
||||||
|
|
||||||
|
End-to-end on a fresh PGlite install:
|
||||||
|
|
||||||
|
- `[DatabaseModule] Applying PGlite schema migrations...` then
|
||||||
|
`Initializing storage adapter (pglite)...` in startup log.
|
||||||
|
- `GET /api/bootstrap/status` → `{"needsSetup":true}` HTTP 200 (was 500
|
||||||
|
with `relation "users" does not exist`).
|
||||||
|
- `POST /api/bootstrap/setup` with empty body → HTTP 400 with Zod
|
||||||
|
validation error (was 500), confirming the request reached the
|
||||||
|
validator past the table-existence check.
|
||||||
|
|
||||||
|
## Known issues, out of scope (file separately)
|
||||||
|
|
||||||
|
- **Postgres-tier first install is still broken.** `runMigrations()` uses
|
||||||
|
Drizzle's `migratePostgres`, which has the same outer-transaction problem
|
||||||
|
as PGlite's migrator. A fresh standalone-tier install would also fail at
|
||||||
|
migration 0009. Inline TODO in `migrate.ts:31-35` flags this. Fixing it
|
||||||
|
needs either (a) a shared per-statement loop reused for both drivers, or
|
||||||
|
(b) splitting migration 0009.
|
||||||
|
- **`drizzle/meta/_journal.json` has 0009 ordered before 0008** (`when`
|
||||||
|
values `1745280000000` < `1776822435828`). `migratePostgres` skips by
|
||||||
|
`created_at < folderMillis`, so on a postgres deployment that already
|
||||||
|
applied 0008, 0009 would be skipped forever. Our hash-based skip in the
|
||||||
|
PGlite path sidesteps this.
|
||||||
|
- **No advisory lock around the migration loop.** Two gateway processes
|
||||||
|
pointed at the same DATABASE_URL would race. PGlite is single-process by
|
||||||
|
file lock so the local tier is fine; postgres-tier deployments should add
|
||||||
|
`pg_advisory_lock(<deterministic-id>)` around the loop in a follow-up.
|
||||||
|
- **`mosaic storage migrate` CLI message is misleading** — it claims
|
||||||
|
"automatic on first connection via adapter.migrate()" but the adapter
|
||||||
|
doesn't self-migrate. With this PR the gateway invokes it explicitly, but
|
||||||
|
the CLI message could still be tightened.
|
||||||
|
- **Crash mid-migration leaves a partial-state PGlite DB without a ledger
|
||||||
|
row.** Detected loudly on next boot (the replay errors on "already
|
||||||
|
exists"), but recovery is manual (drop the partially-applied objects or
|
||||||
|
insert the migration hash into `drizzle.__drizzle_migrations`). A robust
|
||||||
|
fix would add a "started_at" column to a sidecar table to detect
|
||||||
|
half-applied state and refuse to start with actionable guidance.
|
||||||
Reference in New Issue
Block a user