Add external tracker schema introspection

Adds configurable external tracker schema introspection, mapping profiles, validation, dry-run create, and approved mock create support.
This commit is contained in:
Brad Groux 2026-06-26 14:28:59 -05:00 committed by GitHub
parent 7950fec5f4
commit 5bd4377f32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2734 additions and 11 deletions

View file

@ -47,17 +47,18 @@
34. [Cost Prediction](#cost-prediction)
35. [Error Learning](#error-learning)
36. [Reflection-to-Memory Promotion](#reflection-to-memory-promotion)
37. [Tool Policies](#tool-policies)
38. [Watcher Continuation Policies](#watcher-continuation-policies)
39. [Traces](#traces)
40. [Ceremony Requirements](#ceremony-requirements-apiceremonies)
41. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
42. [Audit](#audit)
43. [Maintenance Center](#maintenance-center-apiv1maintenance)
44. [Common Workflows](#common-workflows)
45. [Versioning & Deprecation](#versioning--deprecation)
46. [Rate Limits](#rate-limits)
47. [Additional Endpoint Groups](#additional-endpoint-groups)
37. [External Tracker Introspection](#external-tracker-introspection)
38. [Tool Policies](#tool-policies)
39. [Watcher Continuation Policies](#watcher-continuation-policies)
40. [Traces](#traces)
41. [Ceremony Requirements](#ceremony-requirements-apiceremonies)
42. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
43. [Audit](#audit)
44. [Maintenance Center](#maintenance-center-apiv1maintenance)
45. [Common Workflows](#common-workflows)
46. [Versioning & Deprecation](#versioning--deprecation)
47. [Rate Limits](#rate-limits)
48. [Additional Endpoint Groups](#additional-endpoint-groups)
---
@ -2646,6 +2647,106 @@ Rejected, merged, and deleted candidates remain in the audit trail and do not af
---
## External Tracker Introspection
Configurable external work item schema introspection and mapping lives under `/api/integrations/trackers`. Reads require `settings:read`; writes require `settings:write` through the parent integrations permission guard. External creates also require an explicit `approvedBy` field in the request body.
| Method | Path | Description |
| ------ | --------------------------------------------------------------- | ------------------------------------------------------------- |
| `GET` | `/api/integrations/trackers/connection` | Return redacted connection posture |
| `PUT` | `/api/integrations/trackers/connection` | Save redacted connection metadata; credential values omitted |
| `GET` | `/api/integrations/trackers/schema` | Return the latest normalized tracker schema |
| `POST` | `/api/integrations/trackers/introspect` | Run adapter introspection and refresh the schema |
| `GET` | `/api/integrations/trackers/profiles` | List mapping profiles |
| `PUT` | `/api/integrations/trackers/profiles/:profileId` | Save a mapping profile after schema validation |
| `POST` | `/api/integrations/trackers/profiles/:profileId/validate` | Validate a saved profile |
| `POST` | `/api/integrations/trackers/profiles/:profileId/dry-run-create` | Build and validate a create payload without an external write |
| `POST` | `/api/integrations/trackers/profiles/:profileId/create` | Create a work item after explicit approval |
| `GET` | `/api/integrations/trackers/audits` | List metadata-only sync audit events |
### Introspect Schema
```
POST /api/integrations/trackers/introspect
```
**Body**:
```json
{
"provider": "mock",
"project": "Veritas Kanban"
}
```
**Response** `200`: `ExternalTrackerSchema` with work item types, fields, planning paths, priorities, states, tags, assignees, capabilities, and redacted connection posture.
### Save Mapping Profile
```
PUT /api/integrations/trackers/profiles/default-mock-profile
```
**Body**:
```json
{
"id": "default-mock-profile",
"name": "Default Mock Tracker Mapping",
"provider": "mock",
"enabled": true,
"defaultWorkItemType": "Task",
"defaultProjectPath": "Veritas Kanban",
"defaultAreaPath": "Veritas Kanban\\Platform",
"defaultIterationPath": "Veritas Kanban\\Next",
"fieldMappings": [
{ "trackerFieldId": "System.Title", "source": "title", "required": true },
{ "trackerFieldId": "System.Description", "source": "description" },
{ "trackerFieldId": "Microsoft.VSTS.Common.Priority", "source": "priority" },
{ "trackerFieldId": "System.State", "source": "status" },
{ "trackerFieldId": "System.Tags", "source": "literal", "literalValue": "veritas" }
],
"backlinkFieldId": "Custom.VeritasBacklink"
}
```
Invalid work item types, planning paths, tracker field ids, and required-field gaps return `400 VALIDATION_ERROR`.
### Dry-run Create
```
POST /api/integrations/trackers/profiles/default-mock-profile/dry-run-create
```
**Body**:
```json
{
"taskId": "task_20260626_tracker"
}
```
**Response** `200`: `ExternalTrackerDryRunCreateResult` with `externalWrite: false`, the mapped payload, and validation errors/warnings.
### Approved Create
```
POST /api/integrations/trackers/profiles/default-mock-profile/create
```
**Body**:
```json
{
"taskId": "task_20260626_tracker",
"approvedBy": "brad"
}
```
Successful creates return `201`, append an `externalWorkItems` backlink to the task, and write metadata-only audit/activity events. Credential values and private payload content are not logged.
---
## Search
QMD-ready retrieval across task markdown and docs. The endpoint uses the configured backend and gracefully falls back to keyword search when QMD is unavailable.
@ -3078,6 +3179,7 @@ These endpoints follow the same auth/error patterns documented above:
| `/api/automation` | Automation rules |
| `/api/summary` | Board summaries |
| `/api/github` | GitHub integration |
| `/api/integrations/trackers` | External tracker schema introspection and mapping profiles |
| `/api/conflicts` | Merge conflict detection |
| `/api/watcher-policies` | Agent continuation guardrail decisions |
| `/api/metrics` | Prometheus-style metrics |

View file

@ -22,6 +22,7 @@ For current v5 screenshots and GIFs, see the
- [Code Workflow](#code-workflow)
- [GitHub Issues Sync](#github-issues-sync)
- [External Tracker Introspection](#external-tracker-introspection)
### AI Agents
@ -1322,6 +1323,27 @@ Bidirectional sync between GitHub Issues and your Kanban board.
---
## External Tracker Introspection
Configurable external work item mapping for trackers with custom schemas. GitHub sync remains the default path and is unchanged.
- **Adapter schema contract** — Tracker adapters report work item types, required/optional fields, project/area/team/iteration paths, state/priority/tag/assignee constraints, and dry-run/create capabilities.
- **Mock adapter first pass** — Ships a deterministic mock tracker so mapping, validation, dry-run, and UI workflows can be verified before adding a concrete provider.
- **Settings UI** — Settings -> Trackers can run introspection, choose default type/path/iteration/team values, map Veritas fields to tracker fields, validate a profile, and dry-run create payloads.
- **Mapping profiles** — Profiles store normalized mapping metadata only. Connection posture records whether credentials exist, but credential values are not persisted or returned.
- **Preflight validation** — Required fields, invalid work item types, invalid planning paths, and disallowed picklist/priority values are caught before create/update calls.
- **Approval-gated writes** — External work item creation requires an explicit approver, records metadata-only audit/activity entries, and stores a Veritas backlink on the source task in `externalWorkItems`.
- **API endpoints:**
- `GET /api/integrations/trackers/schema` — Return the latest normalized schema
- `POST /api/integrations/trackers/introspect` — Run adapter introspection
- `GET /api/integrations/trackers/profiles` — List mapping profiles
- `PUT /api/integrations/trackers/profiles/:id` — Save a mapping profile
- `POST /api/integrations/trackers/profiles/:id/validate` — Validate a saved profile
- `POST /api/integrations/trackers/profiles/:id/dry-run-create` — Build and validate a create payload without writing externally
- `POST /api/integrations/trackers/profiles/:id/create` — Create a work item after explicit approval
---
## Activity Feed
Streamlined activity page focused on status history with real-time updates.
@ -1828,6 +1850,7 @@ RESTful API designed for both human and AI agent consumption.
| `/api/v1/conflicts` | Merge conflict status and resolution |
| `/api/v1/github` | GitHub PR creation and Issues sync |
| `/api/v1/github/sync` | GitHub Issues sync (trigger, status, config, mappings) |
| `/api/v1/integrations/trackers` | External tracker introspection, mapping profiles, and dry-run |
| `/api/v1/summary` | Project summary, memory-formatted summary, and standup |
| `/api/v1/summary/standup` | Daily standup summary (json, markdown, text) |
| `/api/v1/notifications` | Notification CRUD and Teams-formatted pending |

View file

@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest';
import type { Task } from '@veritas-kanban/shared';
import {
ExternalTrackerService,
type ExternalTrackerTaskService,
} from '../services/external-tracker-service.js';
import type { ActivityService } from '../services/activity-service.js';
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task_20260626_tracker',
title: 'Add tracker introspection',
description: 'Create a configurable external tracker mapping.',
type: 'feature',
status: 'todo',
priority: 'high',
created: '2026-06-26T12:00:00.000Z',
updated: '2026-06-26T12:00:00.000Z',
...overrides,
} as Task;
}
function createHarness(task: Task = createTask()) {
const audit = vi.fn().mockResolvedValue(undefined);
const updateTask = vi.fn().mockResolvedValue(task);
const taskService: ExternalTrackerTaskService = {
getTask: vi.fn().mockResolvedValue(task),
updateTask,
};
const activity = {
logActivity: vi.fn().mockResolvedValue({ id: 'activity_1' }),
} as unknown as ActivityService;
return {
audit,
activity,
taskService,
updateTask,
service: new ExternalTrackerService({
persist: false,
audit,
taskService,
activity,
}),
};
}
describe('ExternalTrackerService', () => {
it('introspects a normalized mock tracker schema and default mapping profile', async () => {
const { service } = createHarness();
const schema = await service.introspect(
{ provider: 'mock', project: 'Veritas Kanban' },
'brad'
);
const profiles = await service.listProfiles();
expect(schema.workItemTypes.map((item) => item.id)).toEqual(['Bug', 'Feature', 'Task']);
expect(schema.fields.some((field) => field.id === 'System.AreaPath' && field.required)).toBe(
true
);
expect(schema.areaPaths[0].path).toBe('Veritas Kanban\\Platform');
expect(profiles[0]).toMatchObject({
id: 'default-mock-profile',
defaultWorkItemType: 'Task',
backlinkFieldId: 'Custom.VeritasBacklink',
});
});
it('rejects mapping profiles with invalid required planning paths', async () => {
const { service } = createHarness();
const [profile] = await service.listProfiles();
await expect(
service.saveProfile(
{
...profile,
defaultAreaPath: 'Veritas Kanban\\Missing',
},
'brad'
)
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
});
it('catches invalid mapped values during dry-run before create', async () => {
const task = createTask({ priority: 'critical' });
const { service } = createHarness(task);
const [profile] = await service.listProfiles();
await service.saveProfile(
{
...profile,
valueMappings: {
...profile.valueMappings,
priority: { ...profile.valueMappings?.priority, critical: 9 },
},
},
'brad'
);
const result = await service.dryRunCreate({ profileId: profile.id, taskId: task.id }, 'brad');
expect(result.externalWrite).toBe(false);
expect(result.validation.valid).toBe(false);
expect(result.validation.errors[0]).toMatchObject({
code: 'INVALID_FIELD_VALUE',
fieldId: 'Microsoft.VSTS.Common.Priority',
});
});
it('creates an approved mock work item and records a Veritas backlink on the task', async () => {
const task = createTask();
const { service, updateTask, activity } = createHarness(task);
const [profile] = await service.listProfiles();
const result = await service.createWorkItem({
profileId: profile.id,
taskId: task.id,
approvedBy: 'brad',
});
expect(result.externalWrite).toBe(true);
expect(result.link.externalId).toMatch(/^MOCK-/);
expect(result.payload.fields['Custom.VeritasBacklink']).toBe(
'veritas-kanban://tasks/task_20260626_tracker'
);
expect(updateTask).toHaveBeenCalledWith(
task.id,
expect.objectContaining({
externalWorkItems: [
expect.objectContaining({
provider: 'mock',
profileId: profile.id,
externalUrl: expect.stringContaining('/work-items/MOCK-'),
}),
],
})
);
expect(activity.logActivity).toHaveBeenCalledWith(
'agent_event',
task.id,
task.title,
expect.objectContaining({
event: 'external_tracker.work_item_created',
externalId: result.link.externalId,
}),
undefined,
'brad'
);
});
});

View file

@ -0,0 +1,223 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import express, { type NextFunction, type Request, type Response } from 'express';
import request from 'supertest';
const { mockExternalTrackerService } = vi.hoisted(() => ({
mockExternalTrackerService: {
getConnection: vi.fn(),
saveConnection: vi.fn(),
getSchema: vi.fn(),
introspect: vi.fn(),
listProfiles: vi.fn(),
saveProfile: vi.fn(),
validateProfile: vi.fn(),
dryRunCreate: vi.fn(),
createWorkItem: vi.fn(),
listAudits: vi.fn(),
},
}));
vi.mock('../../services/external-tracker-service.js', () => ({
getExternalTrackerService: () => mockExternalTrackerService,
}));
import { externalTrackerRoutes } from '../../routes/external-trackers.js';
interface TestAuthRequest extends Request {
auth?: { role: string; userId?: string; permissions: string[] };
}
interface TestError extends Error {
statusCode?: number;
code?: string;
}
const schema = {
provider: 'mock',
providerLabel: 'Mock Tracker',
schemaVersion: 'mock-2026-06-26',
introspectedAt: '2026-06-26T12:00:00.000Z',
workItemTypes: [{ id: 'Task', name: 'Task' }],
fields: [{ id: 'System.Title', name: 'Title', type: 'string', required: true }],
projects: [{ id: 'project-default', name: 'Veritas', path: 'Veritas', kind: 'project' }],
areaPaths: [{ id: 'area-platform', name: 'Platform', path: 'Veritas\\Platform', kind: 'area' }],
iterationPaths: [
{ id: 'iteration-next', name: 'Next', path: 'Veritas\\Next', kind: 'iteration' },
],
teams: [{ id: 'team-core', name: 'Core', path: 'Veritas\\Core', kind: 'team' }],
priorities: [1, 2, 3, 4],
states: ['New', 'Active', 'Closed'],
tags: ['veritas'],
assignees: [],
capabilities: {
canCreate: true,
canUpdate: true,
requiresApproval: true,
supportsDryRun: true,
},
connectionPosture: { status: 'connected', hasCredential: false, credentialRedacted: true },
};
const profile = {
id: 'default-mock-profile',
name: 'Default Mock Tracker Mapping',
provider: 'mock',
enabled: true,
defaultWorkItemType: 'Task',
defaultProjectPath: 'Veritas',
defaultAreaPath: 'Veritas\\Platform',
fieldMappings: [{ trackerFieldId: 'System.Title', source: 'title', required: true }],
backlinkFieldId: 'Custom.VeritasBacklink',
createdAt: '2026-06-26T12:00:00.000Z',
updatedAt: '2026-06-26T12:00:00.000Z',
};
function createApp() {
const app = express();
app.use(express.json());
app.use((req: TestAuthRequest, _res: Response, next: NextFunction) => {
req.auth = { role: 'admin', userId: 'brad', permissions: ['settings:write'] };
next();
});
app.use('/api/integrations/trackers', externalTrackerRoutes);
app.use((err: TestError, _req: Request, res: Response, _next: NextFunction) => {
res.status(err.statusCode || 500).json({ code: err.code || 'ERROR', message: err.message });
});
return app;
}
describe('external tracker routes', () => {
beforeEach(() => {
vi.clearAllMocks();
mockExternalTrackerService.getConnection.mockResolvedValue({
provider: 'mock',
displayName: 'Mock Tracker',
status: 'connected',
hasCredential: false,
credentialRedacted: true,
updatedAt: '2026-06-26T12:00:00.000Z',
});
mockExternalTrackerService.saveConnection.mockResolvedValue({
provider: 'mock',
displayName: 'Mock Tracker',
status: 'connected',
hasCredential: true,
credentialRedacted: true,
updatedAt: '2026-06-26T12:00:00.000Z',
updatedBy: 'brad',
});
mockExternalTrackerService.getSchema.mockResolvedValue(schema);
mockExternalTrackerService.introspect.mockResolvedValue(schema);
mockExternalTrackerService.listProfiles.mockResolvedValue([profile]);
mockExternalTrackerService.saveProfile.mockResolvedValue(profile);
mockExternalTrackerService.validateProfile.mockResolvedValue({
valid: true,
errors: [],
warnings: [],
});
mockExternalTrackerService.dryRunCreate.mockResolvedValue({
externalWrite: false,
profile,
schema,
payload: {
provider: 'mock',
workItemType: 'Task',
fields: { 'System.Title': 'Preview' },
backlinkUrl: 'veritas-kanban://tasks/task_1',
},
validation: { valid: true, errors: [], warnings: [] },
});
mockExternalTrackerService.createWorkItem.mockResolvedValue({
externalWrite: true,
link: {
id: 'external_work_1',
provider: 'mock',
profileId: profile.id,
externalId: 'MOCK-1',
externalUrl: 'https://tracker.example.test/work-items/MOCK-1',
workItemType: 'Task',
status: 'created',
title: 'Preview',
backlinkUrl: 'veritas-kanban://tasks/task_1',
createdAt: '2026-06-26T12:00:00.000Z',
createdBy: 'brad',
},
profile,
schema,
payload: {
provider: 'mock',
workItemType: 'Task',
fields: { 'System.Title': 'Preview' },
backlinkUrl: 'veritas-kanban://tasks/task_1',
},
validation: { valid: true, errors: [], warnings: [] },
});
mockExternalTrackerService.listAudits.mockResolvedValue([]);
});
it('returns schema and profile configuration', async () => {
const app = createApp();
const schemaRes = await request(app).get('/api/integrations/trackers/schema');
const profileRes = await request(app).get('/api/integrations/trackers/profiles');
expect(schemaRes.status).toBe(200);
expect(schemaRes.body.providerLabel).toBe('Mock Tracker');
expect(profileRes.status).toBe(200);
expect(profileRes.body[0].id).toBe('default-mock-profile');
});
it('saves connection and profile changes with the authenticated actor', async () => {
const app = createApp();
await request(app)
.put('/api/integrations/trackers/connection')
.send({ provider: 'mock', token: 'secret' })
.expect(200);
await request(app)
.put('/api/integrations/trackers/profiles/default-mock-profile')
.send(profile)
.expect(200);
expect(mockExternalTrackerService.saveConnection).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'mock', token: 'secret' }),
'brad'
);
expect(mockExternalTrackerService.saveProfile).toHaveBeenCalledWith(
expect.objectContaining({ id: 'default-mock-profile' }),
'brad'
);
});
it('runs dry-run creates without external writes', async () => {
const res = await request(createApp())
.post('/api/integrations/trackers/profiles/default-mock-profile/dry-run-create')
.send({ taskId: 'task_20260626_tracker' });
expect(res.status).toBe(200);
expect(res.body.externalWrite).toBe(false);
expect(mockExternalTrackerService.dryRunCreate).toHaveBeenCalledWith(
{ profileId: 'default-mock-profile', taskId: 'task_20260626_tracker' },
'brad'
);
});
it('requires explicit approval before creating external work items', async () => {
const app = createApp();
const blocked = await request(app)
.post('/api/integrations/trackers/profiles/default-mock-profile/create')
.send({ taskId: 'task_20260626_tracker' });
const created = await request(app)
.post('/api/integrations/trackers/profiles/default-mock-profile/create')
.send({ taskId: 'task_20260626_tracker', approvedBy: 'brad' });
expect(blocked.status).toBe(400);
expect(created.status).toBe(201);
expect(mockExternalTrackerService.createWorkItem).toHaveBeenCalledTimes(1);
expect(mockExternalTrackerService.createWorkItem).toHaveBeenCalledWith({
profileId: 'default-mock-profile',
taskId: 'task_20260626_tracker',
approvedBy: 'brad',
});
});
});

View file

@ -0,0 +1,209 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { asyncHandler } from '../middleware/async-handler.js';
import type { AuthenticatedRequest } from '../middleware/auth.js';
import { ValidationError } from '../middleware/error-handler.js';
import { getExternalTrackerService } from '../services/external-tracker-service.js';
import type { Task } from '@veritas-kanban/shared';
const router: RouterType = Router();
const providerSchema = z.literal('mock');
const connectionSchema = z.object({
provider: providerSchema.default('mock'),
displayName: z.string().max(120).optional(),
baseUrl: z.string().url().optional(),
organization: z.string().max(120).optional(),
project: z.string().max(120).optional(),
token: z.string().max(4000).optional(),
});
const mappingSourceSchema = z.enum([
'id',
'title',
'description',
'type',
'status',
'priority',
'project',
'sprint',
'github.url',
'literal',
]);
const fieldMappingSchema = z.object({
trackerFieldId: z.string().min(1).max(120),
source: mappingSourceSchema,
literalValue: z.string().max(4000).optional(),
required: z.boolean().optional(),
});
const valueMappingsSchema = z
.object({
priority: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
status: z.record(z.string(), z.string()).optional(),
type: z.record(z.string(), z.string()).optional(),
})
.optional();
const profileSchema = z.object({
id: z.string().min(1).max(120).optional(),
name: z.string().min(1).max(120),
provider: providerSchema.default('mock'),
enabled: z.boolean().optional(),
workspaceId: z.string().max(120).optional(),
project: z.string().max(200).optional(),
defaultWorkItemType: z.string().min(1).max(120),
defaultProjectPath: z.string().max(200).optional(),
defaultAreaPath: z.string().max(200).optional(),
defaultTeamPath: z.string().max(200).optional(),
defaultIterationPath: z.string().max(200).optional(),
fieldMappings: z.array(fieldMappingSchema).min(1).max(50),
valueMappings: valueMappingsSchema,
backlinkFieldId: z.string().max(120).optional(),
});
const dryRunSchema = z
.object({
taskId: z.string().min(1).max(200).optional(),
task: z.custom<Task>().optional(),
})
.refine((value) => value.taskId || value.task, {
message: 'taskId or task is required',
});
const createSchema = dryRunSchema.and(
z.object({
approvedBy: z.string().min(1).max(120),
})
);
const auditQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(500).optional(),
});
function parseOrThrow<T>(schema: z.ZodType<T>, value: unknown): T {
try {
return schema.parse(value);
} catch (error) {
if (error instanceof z.ZodError) {
throw new ValidationError('Validation failed', error.issues);
}
throw error;
}
}
function actorFromRequest(req: AuthenticatedRequest): string {
return (
req.auth?.userId ||
req.auth?.tokenName ||
req.auth?.keyName ||
req.auth?.clientId ||
req.auth?.deviceId ||
req.auth?.role ||
'operator'
);
}
router.get(
'/connection',
asyncHandler(async (_req, res) => {
res.json(await getExternalTrackerService().getConnection());
})
);
router.put(
'/connection',
asyncHandler(async (req: AuthenticatedRequest, res) => {
const body = parseOrThrow(connectionSchema, req.body);
res.json(await getExternalTrackerService().saveConnection(body, actorFromRequest(req)));
})
);
router.get(
'/schema',
asyncHandler(async (req, res) => {
const provider = parseOrThrow(providerSchema.optional().default('mock'), req.query.provider);
res.json(await getExternalTrackerService().getSchema(provider));
})
);
router.post(
'/introspect',
asyncHandler(async (req: AuthenticatedRequest, res) => {
const body = parseOrThrow(
connectionSchema.partial().extend({ provider: providerSchema.default('mock') }),
req.body ?? {}
);
res.json(await getExternalTrackerService().introspect(body, actorFromRequest(req)));
})
);
router.get(
'/profiles',
asyncHandler(async (_req, res) => {
res.json(await getExternalTrackerService().listProfiles());
})
);
router.put(
'/profiles/:profileId',
asyncHandler(async (req: AuthenticatedRequest, res) => {
const body = parseOrThrow(profileSchema, {
...req.body,
id: req.params.profileId,
});
res.json(await getExternalTrackerService().saveProfile(body, actorFromRequest(req)));
})
);
router.post(
'/profiles/:profileId/validate',
asyncHandler(async (req: AuthenticatedRequest, res) => {
res.json(
await getExternalTrackerService().validateProfile(
String(req.params.profileId),
actorFromRequest(req)
)
);
})
);
router.post(
'/profiles/:profileId/dry-run-create',
asyncHandler(async (req: AuthenticatedRequest, res) => {
const body = parseOrThrow(dryRunSchema, req.body);
res.json(
await getExternalTrackerService().dryRunCreate(
{
...body,
profileId: String(req.params.profileId),
},
actorFromRequest(req)
)
);
})
);
router.post(
'/profiles/:profileId/create',
asyncHandler(async (req, res) => {
const body = parseOrThrow(createSchema, req.body);
const result = await getExternalTrackerService().createWorkItem({
...body,
profileId: String(req.params.profileId),
});
res.status(201).json(result);
})
);
router.get(
'/audits',
asyncHandler(async (req, res) => {
const query = parseOrThrow(auditQuerySchema, req.query);
res.json(await getExternalTrackerService().listAudits(query.limit));
})
);
export { router as externalTrackerRoutes };

View file

@ -19,6 +19,7 @@ import {
getCommunicationAdapterService,
} from '../services/communication-adapter-service.js';
import { getOutboundIntegrationService } from '../services/outbound-integration-service.js';
import { externalTrackerRoutes } from './external-trackers.js';
import { safeFetch } from '../utils/url-validation.js';
import { createLogger } from '../lib/logger.js';
@ -31,6 +32,8 @@ const communicationAdapters = getCommunicationAdapterService();
const SERVICE_NAMES = ['supabase', 'openpanel', 'n8n', 'plane', 'appsmith'] as const;
type ServiceName = (typeof SERVICE_NAMES)[number];
router.use('/trackers', externalTrackerRoutes);
/** Timeout for health check pings (ms) */
const PING_TIMEOUT_MS = 5_000;

View file

@ -0,0 +1,998 @@
import fs from 'fs/promises';
import path from 'path';
import { nanoid } from 'nanoid';
import type {
ExternalTrackerConnectionInput,
ExternalTrackerConnectionRecord,
ExternalTrackerCreateWorkItemInput,
ExternalTrackerCreateWorkItemResult,
ExternalTrackerDryRunCreateInput,
ExternalTrackerDryRunCreateResult,
ExternalTrackerField,
ExternalTrackerMappedPayload,
ExternalTrackerMappingProfile,
ExternalTrackerMappingProfileInput,
ExternalTrackerPlanningPath,
ExternalTrackerProvider,
ExternalTrackerSchema,
ExternalTrackerSyncAudit,
ExternalTrackerValidationIssue,
ExternalTrackerValidationResult,
ExternalWorkItemLink,
Task,
UpdateTaskInput,
} from '@veritas-kanban/shared';
import { auditLog, type AuditEvent } from './audit-service.js';
import { activityService, type ActivityService } from './activity-service.js';
import { withFileLock } from './file-lock.js';
import { getTaskService } from './task-service.js';
import { ConflictError, NotFoundError, ValidationError } from '../middleware/error-handler.js';
import { getRuntimeDir } from '../utils/paths.js';
import { stripHtml, validatePathSegment } from '../utils/sanitize.js';
const STATE_FILE = 'state.json';
const DEFAULT_PROFILE_ID = 'default-mock-profile';
const DEFAULT_PROVIDER: ExternalTrackerProvider = 'mock';
const MAX_AUDIT_EVENTS = 500;
interface ExternalTrackerState {
version: 1;
connection?: ExternalTrackerConnectionRecord;
schemas: Partial<Record<ExternalTrackerProvider, ExternalTrackerSchema>>;
profiles: ExternalTrackerMappingProfile[];
audits: ExternalTrackerSyncAudit[];
updatedAt: string;
}
export interface ExternalTrackerTaskService {
getTask(id: string): Promise<Task | null>;
updateTask(id: string, input: UpdateTaskInput): Promise<Task | null>;
}
interface ExternalTrackerAdapter {
provider: ExternalTrackerProvider;
introspect(connection?: ExternalTrackerConnectionRecord): Promise<ExternalTrackerSchema>;
buildCreatePayload(
task: Task,
profile: ExternalTrackerMappingProfile,
schema: ExternalTrackerSchema
): ExternalTrackerMappedPayload;
createWorkItem(input: {
payload: ExternalTrackerMappedPayload;
task: Task;
profile: ExternalTrackerMappingProfile;
approvedBy: string;
}): Promise<{ externalId: string; externalUrl: string; status: string }>;
}
export interface ExternalTrackerServiceOptions {
storageDir?: string;
persist?: boolean;
audit?: (event: AuditEvent) => Promise<void>;
taskService?: ExternalTrackerTaskService;
activity?: ActivityService;
adapters?: ExternalTrackerAdapter[];
}
function nowIso(): string {
return new Date().toISOString();
}
function cleanText(value: string | undefined, fallback = ''): string {
return stripHtml(String(value ?? fallback)).trim();
}
function safeTaskValue(task: Task, source: string, literalValue?: string): string | number | null {
switch (source) {
case 'id':
return task.id;
case 'title':
return task.title;
case 'description':
return task.description;
case 'type':
return task.type;
case 'status':
return task.status;
case 'priority':
return task.priority;
case 'project':
return task.project ?? null;
case 'sprint':
return task.sprint ?? null;
case 'github.url':
return task.github?.url ?? null;
case 'literal':
return literalValue ?? null;
default:
return null;
}
}
function issue(
severity: ExternalTrackerValidationIssue['severity'],
code: string,
message: string,
fieldId?: string,
path?: string
): ExternalTrackerValidationIssue {
return { severity, code, message, fieldId, path };
}
function validationResult(
issues: ExternalTrackerValidationIssue[] = []
): ExternalTrackerValidationResult {
const errors = issues.filter((item) => item.severity === 'error');
const warnings = issues.filter((item) => item.severity === 'warning');
return { valid: errors.length === 0, errors, warnings };
}
function findByPath(paths: ExternalTrackerPlanningPath[], value?: string): boolean {
if (!value) return true;
return paths.some((entry) => entry.path === value || entry.id === value);
}
function fieldById(
schema: ExternalTrackerSchema,
fieldId: string
): ExternalTrackerField | undefined {
return schema.fields.find((field) => field.id === fieldId);
}
function defaultConnection(timestamp = nowIso()): ExternalTrackerConnectionRecord {
return {
provider: DEFAULT_PROVIDER,
displayName: 'Mock Tracker',
status: 'connected',
baseUrl: 'https://tracker.example.test',
organization: 'Veritas',
project: 'Veritas Kanban',
hasCredential: false,
credentialRedacted: true,
updatedAt: timestamp,
updatedBy: 'system',
};
}
function defaultValueMappings(): NonNullable<ExternalTrackerMappingProfile['valueMappings']> {
return {
priority: {
low: 4,
medium: 3,
high: 2,
critical: 1,
},
status: {
todo: 'New',
'in-progress': 'Active',
blocked: 'Active',
done: 'Closed',
cancelled: 'Closed',
},
type: {
feature: 'Feature',
bug: 'Bug',
chore: 'Task',
task: 'Task',
code: 'Task',
},
};
}
function defaultProfile(
schema: ExternalTrackerSchema,
timestamp = nowIso()
): ExternalTrackerMappingProfile {
return {
id: DEFAULT_PROFILE_ID,
name: 'Default Mock Tracker Mapping',
provider: schema.provider,
enabled: true,
project: schema.projects[0]?.path,
defaultWorkItemType: 'Task',
defaultProjectPath: schema.projects[0]?.path,
defaultAreaPath: schema.areaPaths[0]?.path,
defaultTeamPath: schema.teams[0]?.path,
defaultIterationPath: schema.iterationPaths[0]?.path,
fieldMappings: [
{ trackerFieldId: 'System.Title', source: 'title', required: true },
{ trackerFieldId: 'System.Description', source: 'description' },
{ trackerFieldId: 'Microsoft.VSTS.Common.Priority', source: 'priority' },
{ trackerFieldId: 'System.State', source: 'status' },
{ trackerFieldId: 'System.Tags', source: 'literal', literalValue: 'veritas' },
],
valueMappings: defaultValueMappings(),
backlinkFieldId: 'Custom.VeritasBacklink',
createdAt: timestamp,
updatedAt: timestamp,
updatedBy: 'system',
};
}
class MockExternalTrackerAdapter implements ExternalTrackerAdapter {
provider: ExternalTrackerProvider = DEFAULT_PROVIDER;
async introspect(connection?: ExternalTrackerConnectionRecord): Promise<ExternalTrackerSchema> {
const status = connection?.status ?? 'connected';
const hasCredential = connection?.hasCredential ?? false;
const project = connection?.project || 'Veritas Kanban';
const areaRoot = `${project}\\Platform`;
return {
provider: this.provider,
providerLabel: 'Mock Tracker',
schemaVersion: 'mock-2026-06-26',
introspectedAt: nowIso(),
workItemTypes: [
{ id: 'Bug', name: 'Bug', description: 'Defect or regression work item.' },
{ id: 'Feature', name: 'Feature', description: 'User-facing capability or enhancement.' },
{ id: 'Task', name: 'Task', description: 'Implementation or operations work item.' },
],
fields: [
{
id: 'System.Title',
name: 'Title',
type: 'string',
required: true,
description: 'Work item title.',
},
{
id: 'System.Description',
name: 'Description',
type: 'string',
required: false,
description: 'Work item details.',
},
{
id: 'System.WorkItemType',
name: 'Work Item Type',
type: 'picklist',
required: true,
allowedValues: ['Bug', 'Feature', 'Task'],
},
{
id: 'System.AreaPath',
name: 'Area Path',
type: 'picklist',
required: true,
allowedValues: [areaRoot, `${project}\\Product`, `${project}\\Operations`],
},
{
id: 'System.IterationPath',
name: 'Iteration Path',
type: 'picklist',
required: false,
allowedValues: [project, `${project}\\Next`, `${project}\\Later`],
},
{
id: 'System.State',
name: 'State',
type: 'picklist',
required: false,
allowedValues: ['New', 'Active', 'Resolved', 'Closed'],
},
{
id: 'Microsoft.VSTS.Common.Priority',
name: 'Priority',
type: 'number',
required: false,
allowedValues: [1, 2, 3, 4],
},
{
id: 'System.AssignedTo',
name: 'Assigned To',
type: 'identity',
required: false,
allowedValues: ['brad@example.test', 'team@example.test'],
},
{
id: 'System.Tags',
name: 'Tags',
type: 'tags',
required: false,
allowedValues: ['feature', 'bug', 'ops', 'veritas'],
},
{
id: 'Custom.VeritasBacklink',
name: 'Veritas Backlink',
type: 'url',
required: false,
description: 'Link back to the source Veritas task.',
},
],
projects: [{ id: 'project-default', name: project, path: project, kind: 'project' }],
areaPaths: [
{ id: 'area-platform', name: 'Platform', path: areaRoot, kind: 'area' },
{ id: 'area-product', name: 'Product', path: `${project}\\Product`, kind: 'area' },
{ id: 'area-ops', name: 'Operations', path: `${project}\\Operations`, kind: 'area' },
],
iterationPaths: [
{ id: 'iteration-root', name: project, path: project, kind: 'iteration' },
{ id: 'iteration-next', name: 'Next', path: `${project}\\Next`, kind: 'iteration' },
{ id: 'iteration-later', name: 'Later', path: `${project}\\Later`, kind: 'iteration' },
],
teams: [
{ id: 'team-core', name: 'Core', path: `${project}\\Core`, kind: 'team' },
{ id: 'team-ops', name: 'Ops', path: `${project}\\Ops`, kind: 'team' },
],
priorities: [1, 2, 3, 4],
states: ['New', 'Active', 'Resolved', 'Closed'],
tags: ['feature', 'bug', 'ops', 'veritas'],
assignees: ['brad@example.test', 'team@example.test'],
capabilities: {
canCreate: true,
canUpdate: true,
requiresApproval: true,
supportsDryRun: true,
},
connectionPosture: {
status,
hasCredential,
credentialRedacted: true,
},
};
}
buildCreatePayload(
task: Task,
profile: ExternalTrackerMappingProfile,
schema: ExternalTrackerSchema
): ExternalTrackerMappedPayload {
const fields: ExternalTrackerMappedPayload['fields'] = {
'System.WorkItemType':
profile.valueMappings?.type?.[task.type] ?? profile.defaultWorkItemType,
'System.AreaPath': profile.defaultAreaPath ?? null,
};
if (profile.defaultIterationPath) {
fields['System.IterationPath'] = profile.defaultIterationPath;
}
for (const mapping of profile.fieldMappings) {
const field = fieldById(schema, mapping.trackerFieldId);
if (!field || field.readOnly) continue;
let value = safeTaskValue(task, mapping.source, mapping.literalValue);
if (mapping.source === 'priority') {
value = profile.valueMappings?.priority?.[task.priority] ?? value;
} else if (mapping.source === 'status') {
value = profile.valueMappings?.status?.[task.status] ?? value;
} else if (mapping.source === 'type') {
value = profile.valueMappings?.type?.[task.type] ?? value;
}
if (field.type === 'tags' && typeof value === 'string') {
fields[mapping.trackerFieldId] = value ? [value] : [];
} else {
fields[mapping.trackerFieldId] = value;
}
}
const backlinkUrl = `veritas-kanban://tasks/${encodeURIComponent(task.id)}`;
if (profile.backlinkFieldId && fieldById(schema, profile.backlinkFieldId)) {
fields[profile.backlinkFieldId] = backlinkUrl;
}
return {
provider: profile.provider,
workItemType: String(fields['System.WorkItemType'] ?? profile.defaultWorkItemType),
projectPath: profile.defaultProjectPath,
areaPath: profile.defaultAreaPath,
teamPath: profile.defaultTeamPath,
iterationPath: profile.defaultIterationPath,
fields,
backlinkUrl,
};
}
async createWorkItem(_input: {
payload: ExternalTrackerMappedPayload;
task: Task;
profile: ExternalTrackerMappingProfile;
approvedBy: string;
}): Promise<{ externalId: string; externalUrl: string; status: string }> {
const externalId = `MOCK-${Date.now().toString(36).toUpperCase()}-${nanoid(4).toUpperCase()}`;
return {
externalId,
externalUrl: `https://tracker.example.test/work-items/${encodeURIComponent(externalId)}`,
status: 'created',
};
}
}
export class ExternalTrackerService {
private readonly storageDir: string;
private readonly persist: boolean;
private readonly audit: (event: AuditEvent) => Promise<void>;
private readonly taskService: ExternalTrackerTaskService;
private readonly activity: ActivityService;
private readonly adapters: Map<ExternalTrackerProvider, ExternalTrackerAdapter>;
private loaded = false;
private state: ExternalTrackerState = this.emptyState();
constructor(options: ExternalTrackerServiceOptions = {}) {
this.storageDir = options.storageDir ?? path.join(getRuntimeDir(), 'external-trackers');
this.persist = options.persist ?? process.env.VITEST !== 'true';
this.audit = options.audit ?? auditLog;
this.taskService = options.taskService ?? getTaskService();
this.activity = options.activity ?? activityService;
const adapters = options.adapters ?? [new MockExternalTrackerAdapter()];
this.adapters = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
}
async getConnection(): Promise<ExternalTrackerConnectionRecord> {
await this.ensureLoaded();
return this.state.connection ?? defaultConnection();
}
async saveConnection(
input: ExternalTrackerConnectionInput,
actor = 'operator'
): Promise<ExternalTrackerConnectionRecord> {
await this.ensureLoaded();
this.requireAdapter(input.provider);
const timestamp = nowIso();
const record: ExternalTrackerConnectionRecord = {
provider: input.provider,
displayName: cleanText(input.displayName, 'Mock Tracker') || 'Mock Tracker',
status: 'connected',
baseUrl: cleanText(input.baseUrl, 'https://tracker.example.test') || undefined,
organization: cleanText(input.organization, 'Veritas') || undefined,
project: cleanText(input.project, 'Veritas Kanban') || undefined,
hasCredential: Boolean(input.token?.trim()) || this.state.connection?.hasCredential || false,
credentialRedacted: true,
updatedAt: timestamp,
updatedBy: cleanText(actor, 'operator') || 'operator',
};
this.state.connection = record;
this.state.updatedAt = timestamp;
await this.saveState();
await this.audit({
action: 'external_tracker.connection.saved',
actor: record.updatedBy ?? 'operator',
resource: record.provider,
details: {
provider: record.provider,
status: record.status,
hasCredential: record.hasCredential,
},
});
return record;
}
async introspect(
input: Partial<ExternalTrackerConnectionInput> = {},
actor = 'operator'
): Promise<ExternalTrackerSchema> {
await this.ensureLoaded();
const provider = input.provider ?? this.state.connection?.provider ?? DEFAULT_PROVIDER;
const adapter = this.requireAdapter(provider);
if (input.provider || input.baseUrl || input.project || input.organization || input.token) {
await this.saveConnection({ ...input, provider }, actor);
}
const connection = this.state.connection ?? defaultConnection();
const schema = await adapter.introspect(connection);
this.state.schemas[provider] = schema;
this.ensureDefaultProfile(schema, actor);
this.state.updatedAt = nowIso();
await this.saveState();
await this.audit({
action: 'external_tracker.schema.introspected',
actor,
resource: provider,
details: {
provider,
workItemTypes: schema.workItemTypes.length,
fields: schema.fields.length,
areaPaths: schema.areaPaths.length,
},
});
return schema;
}
async getSchema(
provider: ExternalTrackerProvider = DEFAULT_PROVIDER
): Promise<ExternalTrackerSchema> {
await this.ensureLoaded();
const existing = this.state.schemas[provider];
if (existing) return existing;
return this.introspect({ provider }, 'system');
}
async listProfiles(): Promise<ExternalTrackerMappingProfile[]> {
await this.ensureLoaded();
if (this.state.profiles.length === 0) {
const schema = await this.getSchema(DEFAULT_PROVIDER);
this.ensureDefaultProfile(schema, 'system');
await this.saveState();
}
return [...this.state.profiles].sort((a, b) => a.name.localeCompare(b.name));
}
async getProfile(id: string): Promise<ExternalTrackerMappingProfile> {
validatePathSegment(id);
await this.ensureLoaded();
const profile = this.state.profiles.find((item) => item.id === id);
if (!profile) throw new NotFoundError('External tracker mapping profile not found');
return profile;
}
async saveProfile(
input: ExternalTrackerMappingProfileInput,
actor = 'operator'
): Promise<ExternalTrackerMappingProfile> {
await this.ensureLoaded();
this.requireAdapter(input.provider);
const schema = await this.getSchema(input.provider);
const existing = input.id
? this.state.profiles.find((profile) => profile.id === input.id)
: undefined;
const timestamp = nowIso();
const profile: ExternalTrackerMappingProfile = {
id: input.id ? validatePathSegment(input.id) : `tracker_profile_${nanoid(8)}`,
name: cleanText(input.name, 'External Tracker Mapping') || 'External Tracker Mapping',
provider: input.provider,
enabled: input.enabled ?? true,
workspaceId: cleanText(input.workspaceId) || undefined,
project: cleanText(input.project) || input.defaultProjectPath,
defaultWorkItemType: cleanText(input.defaultWorkItemType),
defaultProjectPath: cleanText(input.defaultProjectPath) || undefined,
defaultAreaPath: cleanText(input.defaultAreaPath) || undefined,
defaultTeamPath: cleanText(input.defaultTeamPath) || undefined,
defaultIterationPath: cleanText(input.defaultIterationPath) || undefined,
fieldMappings: input.fieldMappings.map((mapping) => ({
trackerFieldId: cleanText(mapping.trackerFieldId),
source: mapping.source,
literalValue: cleanText(mapping.literalValue) || undefined,
required: mapping.required,
})),
valueMappings: input.valueMappings,
backlinkFieldId: cleanText(input.backlinkFieldId) || undefined,
createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp,
updatedBy: cleanText(actor, 'operator') || 'operator',
};
const validation = this.validateProfileAgainstSchema(profile, schema);
if (!validation.valid) {
await this.recordSyncAudit({
provider: profile.provider,
profileId: profile.id,
operation: 'validate',
status: 'failed',
validation,
actor: profile.updatedBy ?? 'operator',
});
throw new ValidationError('External tracker mapping is invalid', validation.errors);
}
const index = this.state.profiles.findIndex((item) => item.id === profile.id);
if (index === -1) {
this.state.profiles.push(profile);
} else {
this.state.profiles[index] = profile;
}
this.state.updatedAt = timestamp;
await this.saveState();
await this.audit({
action: 'external_tracker.profile.saved',
actor: profile.updatedBy ?? 'operator',
resource: profile.id,
details: {
provider: profile.provider,
enabled: profile.enabled,
workItemType: profile.defaultWorkItemType,
},
});
return profile;
}
async validateProfile(
profileId: string,
actor = 'operator'
): Promise<ExternalTrackerValidationResult> {
const profile = await this.getProfile(profileId);
const schema = await this.getSchema(profile.provider);
const validation = this.validateProfileAgainstSchema(profile, schema);
await this.recordSyncAudit({
provider: profile.provider,
profileId: profile.id,
operation: 'validate',
status: validation.valid ? 'success' : 'failed',
validation,
actor,
});
return validation;
}
async dryRunCreate(
input: ExternalTrackerDryRunCreateInput,
actor = 'operator'
): Promise<ExternalTrackerDryRunCreateResult> {
const { task, profile, schema, payload, validation } = await this.prepareCreate(input);
await this.recordSyncAudit({
provider: profile.provider,
profileId: profile.id,
operation: 'dry-run-create',
status: validation.valid ? 'success' : 'failed',
taskId: task.id,
workItemType: payload.workItemType,
validation,
actor,
});
return {
externalWrite: false,
profile,
schema,
payload,
validation,
};
}
async createWorkItem(
input: ExternalTrackerCreateWorkItemInput
): Promise<ExternalTrackerCreateWorkItemResult> {
const approvedBy = cleanText(input.approvedBy);
if (!approvedBy) {
throw new ValidationError('External tracker creates require explicit approval');
}
const { task, profile, schema, payload, validation } = await this.prepareCreate(input);
if (!validation.valid) {
await this.recordSyncAudit({
provider: profile.provider,
profileId: profile.id,
operation: 'create',
status: 'blocked',
taskId: task.id,
workItemType: payload.workItemType,
validation,
actor: approvedBy,
});
throw new ConflictError('External tracker payload is invalid', validation.errors);
}
const adapter = this.requireAdapter(profile.provider);
const created = await adapter.createWorkItem({ payload, task, profile, approvedBy });
const timestamp = nowIso();
const link: ExternalWorkItemLink = {
id: `external_work_${nanoid(8)}`,
provider: profile.provider,
profileId: profile.id,
externalId: created.externalId,
externalUrl: created.externalUrl,
workItemType: payload.workItemType,
status: created.status,
title: task.title,
backlinkUrl: payload.backlinkUrl,
createdAt: timestamp,
createdBy: approvedBy,
lastSyncAt: timestamp,
};
const existingLinks = task.externalWorkItems ?? [];
await this.taskService.updateTask(task.id, {
externalWorkItems: [...existingLinks, link],
comments: [
...(task.comments ?? []),
{
id: `comment_${Date.now()}_${nanoid(6)}`,
author: approvedBy,
text: `Linked external tracker item ${created.externalId}.`,
timestamp,
},
],
});
await this.activity.logActivity(
'agent_event',
task.id,
task.title,
{
event: 'external_tracker.work_item_created',
provider: profile.provider,
profileId: profile.id,
externalId: created.externalId,
externalUrl: created.externalUrl,
workItemType: payload.workItemType,
},
undefined,
approvedBy
);
await this.recordSyncAudit({
provider: profile.provider,
profileId: profile.id,
operation: 'create',
status: 'success',
taskId: task.id,
externalId: created.externalId,
workItemType: payload.workItemType,
validation,
actor: approvedBy,
});
await this.audit({
action: 'external_tracker.work_item.created',
actor: approvedBy,
resource: task.id,
details: {
provider: profile.provider,
profileId: profile.id,
externalId: created.externalId,
workItemType: payload.workItemType,
},
});
return {
externalWrite: true,
link,
profile,
schema,
payload,
validation,
};
}
async listAudits(limit = 50): Promise<ExternalTrackerSyncAudit[]> {
await this.ensureLoaded();
return this.state.audits.slice(0, Math.max(1, Math.min(limit, MAX_AUDIT_EVENTS)));
}
private async prepareCreate(input: ExternalTrackerDryRunCreateInput): Promise<{
task: Task;
profile: ExternalTrackerMappingProfile;
schema: ExternalTrackerSchema;
payload: ExternalTrackerMappedPayload;
validation: ExternalTrackerValidationResult;
}> {
const profile = await this.getProfile(input.profileId);
const schema = await this.getSchema(profile.provider);
const task = await this.resolveTask(input);
const profileValidation = this.validateProfileAgainstSchema(profile, schema);
const adapter = this.requireAdapter(profile.provider);
const payload = adapter.buildCreatePayload(task, profile, schema);
const payloadValidation = this.validatePayload(payload, schema);
const validation = validationResult([
...profileValidation.errors,
...profileValidation.warnings,
...payloadValidation.errors,
...payloadValidation.warnings,
]);
return { task, profile, schema, payload, validation };
}
private async resolveTask(input: ExternalTrackerDryRunCreateInput): Promise<Task> {
if (input.task) return input.task;
if (!input.taskId) {
throw new ValidationError('A taskId or task payload is required');
}
validatePathSegment(input.taskId);
const task = await this.taskService.getTask(input.taskId);
if (!task) throw new NotFoundError('Task not found');
return task;
}
private validateProfileAgainstSchema(
profile: ExternalTrackerMappingProfile,
schema: ExternalTrackerSchema
): ExternalTrackerValidationResult {
const issues: ExternalTrackerValidationIssue[] = [];
const fields = new Set(schema.fields.map((field) => field.id));
const workItemTypes = new Set(schema.workItemTypes.map((type) => type.id));
if (!profile.name.trim()) {
issues.push(issue('error', 'PROFILE_NAME_REQUIRED', 'Mapping profile name is required'));
}
if (!workItemTypes.has(profile.defaultWorkItemType)) {
issues.push(
issue(
'error',
'INVALID_WORK_ITEM_TYPE',
`Work item type ${profile.defaultWorkItemType || '(empty)'} is not available`,
'System.WorkItemType'
)
);
}
if (!findByPath(schema.projects, profile.defaultProjectPath)) {
issues.push(
issue(
'error',
'INVALID_PROJECT_PATH',
`Project path ${profile.defaultProjectPath} is not available`,
undefined,
'defaultProjectPath'
)
);
}
if (!findByPath(schema.areaPaths, profile.defaultAreaPath)) {
issues.push(
issue(
'error',
'INVALID_AREA_PATH',
`Area path ${profile.defaultAreaPath} is not available`,
'System.AreaPath',
'defaultAreaPath'
)
);
}
if (!findByPath(schema.iterationPaths, profile.defaultIterationPath)) {
issues.push(
issue(
'error',
'INVALID_ITERATION_PATH',
`Iteration path ${profile.defaultIterationPath} is not available`,
'System.IterationPath',
'defaultIterationPath'
)
);
}
if (profile.backlinkFieldId && !fields.has(profile.backlinkFieldId)) {
issues.push(
issue(
'error',
'INVALID_BACKLINK_FIELD',
`Backlink field ${profile.backlinkFieldId} is not available`,
profile.backlinkFieldId
)
);
}
for (const mapping of profile.fieldMappings) {
if (!fields.has(mapping.trackerFieldId)) {
issues.push(
issue(
'error',
'INVALID_FIELD_MAPPING',
`Tracker field ${mapping.trackerFieldId} is not available`,
mapping.trackerFieldId
)
);
}
if (mapping.source === 'literal' && !mapping.literalValue) {
issues.push(
issue(
'warning',
'EMPTY_LITERAL_MAPPING',
`Literal mapping for ${mapping.trackerFieldId} has no value`,
mapping.trackerFieldId
)
);
}
}
const mappedFields = new Set(profile.fieldMappings.map((mapping) => mapping.trackerFieldId));
for (const field of schema.fields.filter((item) => item.required)) {
const hasDefault =
(field.id === 'System.WorkItemType' && profile.defaultWorkItemType) ||
(field.id === 'System.AreaPath' && profile.defaultAreaPath) ||
(field.id === 'System.IterationPath' && profile.defaultIterationPath);
if (!hasDefault && !mappedFields.has(field.id)) {
issues.push(
issue(
'error',
'REQUIRED_FIELD_UNMAPPED',
`Required field ${field.name} is not mapped or configured`,
field.id
)
);
}
}
return validationResult(issues);
}
private validatePayload(
payload: ExternalTrackerMappedPayload,
schema: ExternalTrackerSchema
): ExternalTrackerValidationResult {
const issues: ExternalTrackerValidationIssue[] = [];
for (const field of schema.fields) {
const value = payload.fields[field.id];
const empty =
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0);
if (field.required && empty) {
issues.push(
issue('error', 'REQUIRED_FIELD_EMPTY', `Required field ${field.name} is empty`, field.id)
);
continue;
}
if (empty || !field.allowedValues?.length) continue;
const values = Array.isArray(value) ? value : [value];
const invalidValues = values.filter((item) => !field.allowedValues?.includes(item));
if (invalidValues.length > 0) {
issues.push(
issue(
'error',
'INVALID_FIELD_VALUE',
`${field.name} has invalid value ${invalidValues.join(', ')}`,
field.id
)
);
}
}
return validationResult(issues);
}
private async recordSyncAudit(input: Omit<ExternalTrackerSyncAudit, 'id' | 'createdAt'>) {
await this.ensureLoaded();
const event: ExternalTrackerSyncAudit = {
id: `tracker_audit_${nanoid(8)}`,
createdAt: nowIso(),
...input,
};
this.state.audits = [event, ...this.state.audits].slice(0, MAX_AUDIT_EVENTS);
this.state.updatedAt = event.createdAt;
await this.saveState();
}
private ensureDefaultProfile(schema: ExternalTrackerSchema, actor = 'system'): void {
if (this.state.profiles.some((profile) => profile.provider === schema.provider)) return;
const profile = defaultProfile(schema);
profile.updatedBy = cleanText(actor, 'system') || 'system';
this.state.profiles.push(profile);
}
private requireAdapter(provider: ExternalTrackerProvider): ExternalTrackerAdapter {
const adapter = this.adapters.get(provider);
if (!adapter)
throw new ValidationError(`External tracker provider ${provider} is not supported`);
return adapter;
}
private emptyState(): ExternalTrackerState {
return {
version: 1,
schemas: {},
profiles: [],
audits: [],
updatedAt: nowIso(),
};
}
private get stateFile(): string {
return path.join(this.storageDir, STATE_FILE);
}
private async ensureLoaded(): Promise<void> {
if (this.loaded) return;
if (!this.persist) {
this.state = this.emptyState();
const schema = await this.requireAdapter(DEFAULT_PROVIDER).introspect(defaultConnection());
this.state.schemas[DEFAULT_PROVIDER] = schema;
this.ensureDefaultProfile(schema);
this.loaded = true;
return;
}
await fs.mkdir(this.storageDir, { recursive: true });
try {
const content = await fs.readFile(this.stateFile, 'utf8');
this.state = { ...this.emptyState(), ...JSON.parse(content) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
this.state = this.emptyState();
const schema = await this.requireAdapter(DEFAULT_PROVIDER).introspect(defaultConnection());
this.state.schemas[DEFAULT_PROVIDER] = schema;
this.ensureDefaultProfile(schema);
await this.saveState();
}
this.loaded = true;
}
private async saveState(): Promise<void> {
if (!this.persist) return;
await fs.mkdir(this.storageDir, { recursive: true });
await withFileLock(this.stateFile, async () => {
await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2), 'utf8');
});
}
}
let externalTrackerServiceInstance: ExternalTrackerService | null = null;
export function getExternalTrackerService(): ExternalTrackerService {
if (!externalTrackerServiceInstance) {
externalTrackerServiceInstance = new ExternalTrackerService();
}
return externalTrackerServiceInstance;
}
export function disposeExternalTrackerService(): void {
externalTrackerServiceInstance = null;
}

View file

@ -595,6 +595,7 @@ export class TaskService {
git: data.git,
github: data.github,
delegatedWork: data.delegatedWork,
externalWorkItems: data.externalWorkItems,
attempt: data.attempt,
attempts: data.attempts,
reviewComments,

View file

@ -0,0 +1,234 @@
import type { Task, TaskPriority, TaskStatus, TaskType } from './task.types.js';
export type ExternalTrackerProvider = 'mock';
export type ExternalTrackerFieldType =
| 'string'
| 'number'
| 'boolean'
| 'date'
| 'identity'
| 'picklist'
| 'tags'
| 'url';
export type ExternalTrackerPathKind = 'project' | 'area' | 'iteration' | 'team';
export type ExternalTrackerConnectionStatus = 'connected' | 'disconnected' | 'needs-auth';
export interface ExternalTrackerConnectionInput {
provider: ExternalTrackerProvider;
displayName?: string;
baseUrl?: string;
organization?: string;
project?: string;
token?: string;
}
export interface ExternalTrackerConnectionRecord {
provider: ExternalTrackerProvider;
displayName: string;
status: ExternalTrackerConnectionStatus;
baseUrl?: string;
organization?: string;
project?: string;
hasCredential: boolean;
credentialRedacted: boolean;
updatedAt: string;
updatedBy?: string;
}
export interface ExternalTrackerWorkItemType {
id: string;
name: string;
description?: string;
}
export interface ExternalTrackerField {
id: string;
name: string;
type: ExternalTrackerFieldType;
required: boolean;
readOnly?: boolean;
description?: string;
allowedValues?: Array<string | number | boolean>;
supportedWorkItemTypes?: string[];
}
export interface ExternalTrackerPlanningPath {
id: string;
name: string;
path: string;
kind: ExternalTrackerPathKind;
}
export interface ExternalTrackerSchema {
provider: ExternalTrackerProvider;
providerLabel: string;
schemaVersion: string;
introspectedAt: string;
workItemTypes: ExternalTrackerWorkItemType[];
fields: ExternalTrackerField[];
projects: ExternalTrackerPlanningPath[];
areaPaths: ExternalTrackerPlanningPath[];
iterationPaths: ExternalTrackerPlanningPath[];
teams: ExternalTrackerPlanningPath[];
priorities: Array<string | number>;
states: string[];
tags: string[];
assignees: string[];
capabilities: {
canCreate: boolean;
canUpdate: boolean;
requiresApproval: boolean;
supportsDryRun: boolean;
};
connectionPosture: {
status: ExternalTrackerConnectionStatus;
hasCredential: boolean;
credentialRedacted: boolean;
};
}
export type VeritasTaskMappingField =
| 'id'
| 'title'
| 'description'
| 'type'
| 'status'
| 'priority'
| 'project'
| 'sprint'
| 'github.url'
| 'literal';
export interface ExternalTrackerFieldMapping {
trackerFieldId: string;
source: VeritasTaskMappingField;
literalValue?: string;
required?: boolean;
}
export interface ExternalTrackerValueMappings {
priority?: Partial<Record<TaskPriority, string | number>>;
status?: Partial<Record<TaskStatus, string>>;
type?: Partial<Record<TaskType, string>>;
}
export interface ExternalTrackerMappingProfile {
id: string;
name: string;
provider: ExternalTrackerProvider;
enabled: boolean;
workspaceId?: string;
project?: string;
defaultWorkItemType: string;
defaultProjectPath?: string;
defaultAreaPath?: string;
defaultTeamPath?: string;
defaultIterationPath?: string;
fieldMappings: ExternalTrackerFieldMapping[];
valueMappings?: ExternalTrackerValueMappings;
backlinkFieldId?: string;
createdAt: string;
updatedAt: string;
updatedBy?: string;
}
export interface ExternalTrackerMappingProfileInput {
id?: string;
name: string;
provider: ExternalTrackerProvider;
enabled?: boolean;
workspaceId?: string;
project?: string;
defaultWorkItemType: string;
defaultProjectPath?: string;
defaultAreaPath?: string;
defaultTeamPath?: string;
defaultIterationPath?: string;
fieldMappings: ExternalTrackerFieldMapping[];
valueMappings?: ExternalTrackerValueMappings;
backlinkFieldId?: string;
}
export interface ExternalTrackerValidationIssue {
severity: 'error' | 'warning';
code: string;
message: string;
fieldId?: string;
path?: string;
}
export interface ExternalTrackerValidationResult {
valid: boolean;
errors: ExternalTrackerValidationIssue[];
warnings: ExternalTrackerValidationIssue[];
}
export interface ExternalTrackerMappedPayload {
provider: ExternalTrackerProvider;
workItemType: string;
projectPath?: string;
areaPath?: string;
teamPath?: string;
iterationPath?: string;
fields: Record<string, string | number | boolean | string[] | null>;
backlinkUrl: string;
}
export interface ExternalTrackerDryRunCreateInput {
profileId: string;
taskId?: string;
task?: Task;
}
export interface ExternalTrackerDryRunCreateResult {
externalWrite: false;
profile: ExternalTrackerMappingProfile;
schema: ExternalTrackerSchema;
payload: ExternalTrackerMappedPayload;
validation: ExternalTrackerValidationResult;
}
export interface ExternalWorkItemLink {
id: string;
provider: ExternalTrackerProvider;
profileId: string;
externalId: string;
externalUrl: string;
workItemType: string;
status: string;
title: string;
backlinkUrl: string;
createdAt: string;
createdBy: string;
lastSyncAt?: string;
}
export interface ExternalTrackerCreateWorkItemInput extends ExternalTrackerDryRunCreateInput {
approvedBy: string;
}
export interface ExternalTrackerCreateWorkItemResult {
externalWrite: true;
link: ExternalWorkItemLink;
profile: ExternalTrackerMappingProfile;
schema: ExternalTrackerSchema;
payload: ExternalTrackerMappedPayload;
validation: ExternalTrackerValidationResult;
}
export interface ExternalTrackerSyncAudit {
id: string;
provider: ExternalTrackerProvider;
profileId: string;
operation: 'dry-run-create' | 'create' | 'validate';
status: 'success' | 'failed' | 'blocked';
taskId?: string;
externalId?: string;
workItemType?: string;
validation: ExternalTrackerValidationResult;
actor: string;
createdAt: string;
}

View file

@ -10,6 +10,7 @@ export * from './chat.types.js';
export * from './communication-adapter.types.js';
export * from './ceremony.types.js';
export * from './reflection.types.js';
export * from './external-tracker.types.js';
export * from './transition-hooks.types.js';
export * from './delegation.types.js';
export * from './changes.types.js';

View file

@ -242,6 +242,9 @@ export interface Task {
// Cross-workspace delegation status links
delegatedWork?: import('./workspace-capability.types.js').TaskDelegatedWorkLink[];
// External tracker backlinks created through configured integration mappings
externalWorkItems?: import('./external-tracker.types.js').ExternalWorkItemLink[];
// Current attempt
attempt?: TaskAttempt;
@ -393,6 +396,7 @@ export interface UpdateTaskInput {
git?: Partial<TaskGit>;
github?: TaskGitHub;
delegatedWork?: import('./workspace-capability.types.js').TaskDelegatedWorkLink[];
externalWorkItems?: import('./external-tracker.types.js').ExternalWorkItemLink[];
attempt?: TaskAttempt;
reviewComments?: ReviewComment[];
reviewScores?: [number, number, number, number];

View file

@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
import { TrackersTab } from '@/components/settings/tabs/TrackersTab';
import { renderWithProviders } from './test-utils';
const schema = {
provider: 'mock',
providerLabel: 'Mock Tracker',
schemaVersion: 'mock-2026-06-26',
introspectedAt: '2026-06-26T12:00:00.000Z',
workItemTypes: [
{ id: 'Bug', name: 'Bug' },
{ id: 'Feature', name: 'Feature' },
{ id: 'Task', name: 'Task' },
],
fields: [
{ id: 'System.Title', name: 'Title', type: 'string', required: true },
{ id: 'System.Description', name: 'Description', type: 'string', required: false },
{
id: 'Microsoft.VSTS.Common.Priority',
name: 'Priority',
type: 'number',
required: false,
allowedValues: [1, 2, 3, 4],
},
{ id: 'System.State', name: 'State', type: 'picklist', required: false },
{ id: 'System.Tags', name: 'Tags', type: 'tags', required: false },
{ id: 'Custom.VeritasBacklink', name: 'Veritas Backlink', type: 'url', required: false },
],
projects: [{ id: 'project-default', name: 'Veritas', path: 'Veritas', kind: 'project' }],
areaPaths: [{ id: 'area-platform', name: 'Platform', path: 'Veritas\\Platform', kind: 'area' }],
iterationPaths: [
{ id: 'iteration-next', name: 'Next', path: 'Veritas\\Next', kind: 'iteration' },
],
teams: [{ id: 'team-core', name: 'Core', path: 'Veritas\\Core', kind: 'team' }],
priorities: [1, 2, 3, 4],
states: ['New', 'Active', 'Closed'],
tags: ['veritas'],
assignees: [],
capabilities: {
canCreate: true,
canUpdate: true,
requiresApproval: true,
supportsDryRun: true,
},
connectionPosture: { status: 'connected', hasCredential: false, credentialRedacted: true },
};
const profile = {
id: 'default-mock-profile',
name: 'Default Mock Tracker Mapping',
provider: 'mock',
enabled: true,
defaultWorkItemType: 'Task',
defaultProjectPath: 'Veritas',
defaultAreaPath: 'Veritas\\Platform',
defaultTeamPath: 'Veritas\\Core',
defaultIterationPath: 'Veritas\\Next',
fieldMappings: [
{ trackerFieldId: 'System.Title', source: 'title', required: true },
{ trackerFieldId: 'System.Description', source: 'description' },
{ trackerFieldId: 'Microsoft.VSTS.Common.Priority', source: 'priority' },
{ trackerFieldId: 'System.State', source: 'status' },
{ trackerFieldId: 'System.Tags', source: 'literal', literalValue: 'veritas' },
],
backlinkFieldId: 'Custom.VeritasBacklink',
createdAt: '2026-06-26T12:00:00.000Z',
updatedAt: '2026-06-26T12:00:00.000Z',
};
const mocks = vi.hoisted(() => ({
hasPermission: vi.fn(),
toast: vi.fn(),
trackerSchema: vi.fn(),
trackerProfiles: vi.fn(),
introspectTracker: vi.fn(),
saveTrackerProfile: vi.fn(),
validateTrackerProfile: vi.fn(),
dryRunTrackerCreate: vi.fn(),
}));
vi.mock('@/hooks/useIdentity', () => ({
useIdentity: () => ({ hasPermission: mocks.hasPermission }),
}));
vi.mock('@/hooks/useToast', () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock('@/lib/api', () => ({
api: {
integrations: {
trackerSchema: mocks.trackerSchema,
trackerProfiles: mocks.trackerProfiles,
introspectTracker: mocks.introspectTracker,
saveTrackerProfile: mocks.saveTrackerProfile,
validateTrackerProfile: mocks.validateTrackerProfile,
dryRunTrackerCreate: mocks.dryRunTrackerCreate,
},
},
}));
describe('Trackers settings tab', () => {
beforeEach(() => {
mocks.hasPermission.mockReturnValue(true);
mocks.trackerSchema.mockResolvedValue(schema);
mocks.trackerProfiles.mockResolvedValue([profile]);
mocks.introspectTracker.mockResolvedValue(schema);
mocks.saveTrackerProfile.mockResolvedValue(profile);
mocks.validateTrackerProfile.mockResolvedValue({ valid: true, errors: [], warnings: [] });
mocks.dryRunTrackerCreate.mockResolvedValue({
externalWrite: false,
profile,
schema,
payload: {
provider: 'mock',
workItemType: 'Task',
fields: { 'System.Title': 'Preview tracker mapping' },
backlinkUrl: 'veritas-kanban://tasks/task_tracker_preview',
},
validation: { valid: true, errors: [], warnings: [] },
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('renders schema, saves the mapping profile, and runs a dry-run create', async () => {
renderWithProviders(<TrackersTab />);
expect(await screen.findByText('External Trackers')).toBeDefined();
expect(screen.getByText('Mock Tracker')).toBeDefined();
expect(screen.getByText('6 fields')).toBeDefined();
fireEvent.click(screen.getByRole('button', { name: /Introspect/ }));
await waitFor(() => {
expect(mocks.introspectTracker).toHaveBeenCalledWith({ provider: 'mock' });
});
fireEvent.click(screen.getByRole('button', { name: /Save/ }));
await waitFor(() => {
expect(mocks.saveTrackerProfile).toHaveBeenCalledWith(
'default-mock-profile',
expect.objectContaining({
id: 'default-mock-profile',
defaultWorkItemType: 'Task',
backlinkFieldId: 'Custom.VeritasBacklink',
})
);
});
fireEvent.click(screen.getByRole('button', { name: /Dry Run/ }));
await waitFor(() => {
expect(mocks.dryRunTrackerCreate).toHaveBeenCalledWith(
expect.objectContaining({
profileId: 'default-mock-profile',
task: expect.objectContaining({ id: 'task_tracker_preview' }),
})
);
});
});
});

View file

@ -26,6 +26,7 @@ import {
Network,
CalendarClock,
BrainCircuit,
Waypoints,
} from 'lucide-react';
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import type { ClientAuthPermission } from '@veritas-kanban/shared';
@ -84,6 +85,9 @@ const LazyQueueMonitorsTab = lazy(() =>
const LazyReflectionTab = lazy(() =>
import('./tabs/ReflectionTab').then((m) => ({ default: m.ReflectionTab }))
);
const LazyTrackersTab = lazy(() =>
import('./tabs/TrackersTab').then((m) => ({ default: m.TrackersTab }))
);
// ============ Tab Skeleton ============
@ -120,6 +124,7 @@ type TabId =
| 'scheduler'
| 'queue-monitors'
| 'reflections'
| 'trackers'
| 'maintenance'
| 'manage';
@ -163,6 +168,12 @@ const TABS: TabDef[] = [
icon: BrainCircuit,
requiredPermission: 'workflow:read',
},
{
id: 'trackers',
label: 'Trackers',
icon: Waypoints,
requiredPermission: 'settings:read',
},
{ id: 'maintenance', label: 'Maintenance', icon: Wrench, requiredPermission: 'backup:read' },
{ id: 'delegation', label: 'Delegation', icon: Plane, requiredPermission: 'agent:read' },
{ id: 'tool-policies', label: 'Tool Policies', icon: Lock, requiredPermission: 'policy:read' },
@ -462,6 +473,11 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
<LazyReflectionTab />
</SettingsErrorBoundary>
)}
{activeTab === 'trackers' && (
<SettingsErrorBoundary tabName="Trackers">
<LazyTrackersTab />
</SettingsErrorBoundary>
)}
{activeTab === 'delegation' && (
<SettingsErrorBoundary tabName="Delegation">
<LazyDelegationTab />

View file

@ -0,0 +1,460 @@
import { useEffect, useMemo, useState } from 'react';
import { Badge, Button, Group, Loader, Paper, Select, Stack, Text, TextInput } from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Play, RefreshCw, Save, SearchCode, XCircle } from 'lucide-react';
import type {
ExternalTrackerFieldMapping,
ExternalTrackerMappingProfile,
ExternalTrackerMappingProfileInput,
ExternalTrackerSchema,
ExternalTrackerValidationResult,
Task,
VeritasTaskMappingField,
} from '@veritas-kanban/shared';
import { useIdentity } from '@/hooks/useIdentity';
import { useToast } from '@/hooks/useToast';
import { api } from '@/lib/api';
const TRACKERS_QUERY_KEY = ['settings', 'external-trackers'] as const;
const SOURCE_OPTIONS: { value: VeritasTaskMappingField; label: string }[] = [
{ value: 'title', label: 'Title' },
{ value: 'description', label: 'Description' },
{ value: 'priority', label: 'Priority' },
{ value: 'status', label: 'Status' },
{ value: 'type', label: 'Type' },
{ value: 'project', label: 'Project' },
{ value: 'sprint', label: 'Sprint' },
{ value: 'github.url', label: 'GitHub URL' },
{ value: 'literal', label: 'Literal' },
];
function defaultMappings(): ExternalTrackerFieldMapping[] {
return [
{ trackerFieldId: 'System.Title', source: 'title', required: true },
{ trackerFieldId: 'System.Description', source: 'description' },
{ trackerFieldId: 'Microsoft.VSTS.Common.Priority', source: 'priority' },
{ trackerFieldId: 'System.State', source: 'status' },
{ trackerFieldId: 'System.Tags', source: 'literal', literalValue: 'veritas' },
];
}
function buildDraft(
schema: ExternalTrackerSchema,
profile?: ExternalTrackerMappingProfile
): ExternalTrackerMappingProfileInput {
if (profile) {
return {
id: profile.id,
name: profile.name,
provider: profile.provider,
enabled: profile.enabled,
workspaceId: profile.workspaceId,
project: profile.project,
defaultWorkItemType: profile.defaultWorkItemType,
defaultProjectPath: profile.defaultProjectPath,
defaultAreaPath: profile.defaultAreaPath,
defaultTeamPath: profile.defaultTeamPath,
defaultIterationPath: profile.defaultIterationPath,
fieldMappings: profile.fieldMappings,
valueMappings: profile.valueMappings,
backlinkFieldId: profile.backlinkFieldId,
};
}
return {
id: 'default-mock-profile',
name: 'Default Mock Tracker Mapping',
provider: schema.provider,
enabled: true,
project: schema.projects[0]?.path,
defaultWorkItemType: schema.workItemTypes[0]?.id ?? 'Task',
defaultProjectPath: schema.projects[0]?.path,
defaultAreaPath: schema.areaPaths[0]?.path,
defaultTeamPath: schema.teams[0]?.path,
defaultIterationPath: schema.iterationPaths[0]?.path,
fieldMappings: defaultMappings(),
valueMappings: {
priority: { low: 4, medium: 3, high: 2, critical: 1 },
status: {
todo: 'New',
'in-progress': 'Active',
blocked: 'Active',
done: 'Closed',
cancelled: 'Closed',
},
type: { feature: 'Feature', bug: 'Bug', chore: 'Task', task: 'Task', code: 'Task' },
},
backlinkFieldId: 'Custom.VeritasBacklink',
};
}
function sampleTask(): Task {
return {
id: 'task_tracker_preview',
title: 'Preview tracker mapping',
description: 'Dry-run preview from Settings.',
type: 'feature',
status: 'todo',
priority: 'medium',
created: new Date().toISOString(),
updated: new Date().toISOString(),
};
}
function validationColor(validation?: ExternalTrackerValidationResult): string {
if (!validation) return 'gray';
return validation.valid ? 'green' : 'red';
}
export function TrackersTab() {
const queryClient = useQueryClient();
const { hasPermission } = useIdentity();
const { toast } = useToast();
const canWrite = hasPermission('settings:write');
const [draft, setDraft] = useState<ExternalTrackerMappingProfileInput | null>(null);
const [validation, setValidation] = useState<ExternalTrackerValidationResult | null>(null);
const [taskId, setTaskId] = useState('');
const [dryRunFields, setDryRunFields] = useState<string[]>([]);
const schemaQuery = useQuery({
queryKey: [...TRACKERS_QUERY_KEY, 'schema'],
queryFn: () => api.integrations.trackerSchema(),
staleTime: 60_000,
});
const profilesQuery = useQuery({
queryKey: [...TRACKERS_QUERY_KEY, 'profiles'],
queryFn: () => api.integrations.trackerProfiles(),
staleTime: 60_000,
});
const schema = schemaQuery.data;
const firstProfile = profilesQuery.data?.[0];
useEffect(() => {
if (!schema || draft) return;
setDraft(buildDraft(schema, firstProfile));
}, [draft, firstProfile, schema]);
const fieldOptions = useMemo(
() =>
schema?.fields.map((field) => ({ value: field.id, label: `${field.name} (${field.id})` })) ??
[],
[schema]
);
const workItemTypeOptions = useMemo(
() => schema?.workItemTypes.map((item) => ({ value: item.id, label: item.name })) ?? [],
[schema]
);
const projectOptions = useMemo(
() => schema?.projects.map((item) => ({ value: item.path, label: item.path })) ?? [],
[schema]
);
const areaOptions = useMemo(
() => schema?.areaPaths.map((item) => ({ value: item.path, label: item.path })) ?? [],
[schema]
);
const teamOptions = useMemo(
() => schema?.teams.map((item) => ({ value: item.path, label: item.path })) ?? [],
[schema]
);
const iterationOptions = useMemo(
() => schema?.iterationPaths.map((item) => ({ value: item.path, label: item.path })) ?? [],
[schema]
);
const invalidate = () => queryClient.invalidateQueries({ queryKey: TRACKERS_QUERY_KEY });
const introspect = useMutation({
mutationFn: () => api.integrations.introspectTracker({ provider: 'mock' }),
onSuccess: async (nextSchema) => {
setDraft((current) => current ?? buildDraft(nextSchema));
await invalidate();
toast({ title: 'Tracker schema refreshed' });
},
onError: (error) => {
toast({
title: 'Tracker introspection failed',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
},
});
const saveProfile = useMutation({
mutationFn: (input: ExternalTrackerMappingProfileInput) =>
api.integrations.saveTrackerProfile(input.id ?? 'default-mock-profile', input),
onSuccess: async () => {
await invalidate();
toast({ title: 'Tracker mapping saved' });
},
onError: (error) => {
toast({
title: 'Tracker mapping failed',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
},
});
const validateProfile = useMutation({
mutationFn: async () => {
if (!draft?.id) throw new Error('Save the mapping before validation');
return api.integrations.validateTrackerProfile(draft.id);
},
onSuccess: (result) => {
setValidation(result);
toast({ title: result.valid ? 'Tracker mapping valid' : 'Tracker mapping has errors' });
},
});
const dryRun = useMutation({
mutationFn: async () => {
if (!draft?.id) throw new Error('Save the mapping before dry-run');
return api.integrations.dryRunTrackerCreate({
profileId: draft.id,
taskId: taskId.trim() || undefined,
task: taskId.trim() ? undefined : sampleTask(),
});
},
onSuccess: (result) => {
setValidation(result.validation);
setDryRunFields(Object.keys(result.payload.fields));
toast({ title: result.validation.valid ? 'Dry-run valid' : 'Dry-run found errors' });
},
});
const updateDraft = (patch: Partial<ExternalTrackerMappingProfileInput>) => {
setDraft((current) => (current ? { ...current, ...patch } : current));
};
const updateMapping = (index: number, patch: Partial<ExternalTrackerFieldMapping>) => {
setDraft((current) => {
if (!current) return current;
const fieldMappings = current.fieldMappings.map((mapping, itemIndex) =>
itemIndex === index ? { ...mapping, ...patch } : mapping
);
return { ...current, fieldMappings };
});
};
if (schemaQuery.isLoading || profilesQuery.isLoading || !draft) {
return (
<Group gap="sm" className="text-muted-foreground">
<Loader size="xs" />
<Text size="sm">Loading tracker settings...</Text>
</Group>
);
}
return (
<Stack gap="lg">
<Group justify="space-between" align="center">
<Stack gap={2}>
<Text size="sm" fw={600}>
External Trackers
</Text>
<Group gap="xs">
<Badge variant="light" color="blue">
{schema?.providerLabel ?? 'Mock Tracker'}
</Badge>
<Badge
variant="light"
color={schema?.connectionPosture.status === 'connected' ? 'green' : 'yellow'}
>
{schema?.connectionPosture.status ?? 'unknown'}
</Badge>
<Badge variant="light" color="gray">
{schema?.fields.length ?? 0} fields
</Badge>
<Badge variant="light" color="gray">
{schema?.workItemTypes.length ?? 0} types
</Badge>
</Group>
</Stack>
<Button
size="xs"
variant="subtle"
color="gray"
leftSection={<SearchCode className="h-3.5 w-3.5" />}
onClick={() => introspect.mutate()}
loading={introspect.isPending}
disabled={!canWrite}
>
Introspect
</Button>
</Group>
<Paper className="border bg-card p-4" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="sm" fw={600}>
Mapping Profile
</Text>
<Badge variant="light" color={validationColor(validation ?? undefined)}>
{validation
? validation.valid
? 'valid'
: `${validation.errors.length} errors`
: 'not checked'}
</Badge>
</Group>
<TextInput
label="Profile"
value={draft.name}
onChange={(event) => updateDraft({ name: event.currentTarget.value })}
disabled={!canWrite}
/>
<Group grow align="flex-start">
<Select
label="Type"
data={workItemTypeOptions}
value={draft.defaultWorkItemType}
onChange={(value) => value && updateDraft({ defaultWorkItemType: value })}
disabled={!canWrite}
/>
<Select
label="Project"
data={projectOptions}
value={draft.defaultProjectPath ?? null}
onChange={(value) => updateDraft({ defaultProjectPath: value ?? undefined })}
disabled={!canWrite}
/>
</Group>
<Group grow align="flex-start">
<Select
label="Area"
data={areaOptions}
value={draft.defaultAreaPath ?? null}
onChange={(value) => updateDraft({ defaultAreaPath: value ?? undefined })}
disabled={!canWrite}
/>
<Select
label="Iteration"
data={iterationOptions}
value={draft.defaultIterationPath ?? null}
onChange={(value) => updateDraft({ defaultIterationPath: value ?? undefined })}
disabled={!canWrite}
/>
<Select
label="Team"
data={teamOptions}
value={draft.defaultTeamPath ?? null}
onChange={(value) => updateDraft({ defaultTeamPath: value ?? undefined })}
disabled={!canWrite}
/>
</Group>
</Stack>
</Paper>
<Paper className="border bg-card p-4" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>
Fields
</Text>
{draft.fieldMappings.map((mapping, index) => (
<Group key={`${mapping.trackerFieldId}-${index}`} grow align="flex-end">
<Select
label={index === 0 ? 'Tracker field' : undefined}
data={fieldOptions}
value={mapping.trackerFieldId}
onChange={(value) => value && updateMapping(index, { trackerFieldId: value })}
disabled={!canWrite}
/>
<Select
label={index === 0 ? 'Veritas field' : undefined}
data={SOURCE_OPTIONS}
value={mapping.source}
onChange={(value) =>
value && updateMapping(index, { source: value as VeritasTaskMappingField })
}
disabled={!canWrite}
/>
<TextInput
label={index === 0 ? 'Literal' : undefined}
value={mapping.literalValue ?? ''}
onChange={(event) =>
updateMapping(index, { literalValue: event.currentTarget.value })
}
disabled={!canWrite || mapping.source !== 'literal'}
/>
</Group>
))}
</Stack>
</Paper>
<Paper className="border bg-card p-4" radius="md">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text size="sm" fw={600}>
Dry Run
</Text>
{validation ? (
validation.valid ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)
) : null}
</Group>
<TextInput
label="Task ID"
placeholder="optional"
value={taskId}
onChange={(event) => setTaskId(event.currentTarget.value)}
/>
{validation && !validation.valid ? (
<Stack gap={4}>
{validation.errors.slice(0, 3).map((item) => (
<Text key={`${item.code}-${item.fieldId ?? item.message}`} size="xs" c="red">
{item.message}
</Text>
))}
</Stack>
) : dryRunFields.length > 0 ? (
<Text size="xs" c="dimmed">
Payload: {dryRunFields.join(', ')}
</Text>
) : null}
</Stack>
</Paper>
<Group justify="flex-end">
<Button
size="xs"
variant="subtle"
color="gray"
leftSection={<RefreshCw className="h-3.5 w-3.5" />}
onClick={() => validateProfile.mutate()}
loading={validateProfile.isPending}
disabled={!draft.id}
>
Validate
</Button>
<Button
size="xs"
variant="subtle"
color="gray"
leftSection={<Play className="h-3.5 w-3.5" />}
onClick={() => dryRun.mutate()}
loading={dryRun.isPending}
disabled={!draft.id}
>
Dry Run
</Button>
<Button
size="xs"
leftSection={<Save className="h-3.5 w-3.5" />}
onClick={() => saveProfile.mutate(draft)}
loading={saveProfile.isPending}
disabled={!canWrite}
>
Save
</Button>
</Group>
</Stack>
);
}

View file

@ -106,6 +106,17 @@ export type {
CommunicationSendInput,
CommunicationSendResult,
CommunicationThreadMapping,
ExternalTrackerConnectionInput,
ExternalTrackerConnectionRecord,
ExternalTrackerCreateWorkItemInput,
ExternalTrackerCreateWorkItemResult,
ExternalTrackerDryRunCreateInput,
ExternalTrackerDryRunCreateResult,
ExternalTrackerMappingProfile,
ExternalTrackerMappingProfileInput,
ExternalTrackerSchema,
ExternalTrackerSyncAudit,
ExternalTrackerValidationResult,
} from '@veritas-kanban/shared';
export type {
OutboundDeliveryAttempt,

View file

@ -9,6 +9,17 @@ import type {
CommunicationSendInput,
CommunicationSendResult,
CommunicationThreadMapping,
ExternalTrackerConnectionInput,
ExternalTrackerConnectionRecord,
ExternalTrackerCreateWorkItemInput,
ExternalTrackerCreateWorkItemResult,
ExternalTrackerDryRunCreateInput,
ExternalTrackerDryRunCreateResult,
ExternalTrackerMappingProfile,
ExternalTrackerMappingProfileInput,
ExternalTrackerSchema,
ExternalTrackerSyncAudit,
ExternalTrackerValidationResult,
} from '@veritas-kanban/shared';
export type OutboundEndpointType =
@ -189,4 +200,117 @@ export const integrationsApi = {
});
return handleResponse<CommunicationDeliveryAudit[]>(response);
},
trackerConnection: async (): Promise<ExternalTrackerConnectionRecord> => {
const response = await fetch(`${API_BASE}/integrations/trackers/connection`, {
credentials: 'include',
});
return handleResponse<ExternalTrackerConnectionRecord>(response);
},
saveTrackerConnection: async (
input: ExternalTrackerConnectionInput
): Promise<ExternalTrackerConnectionRecord> => {
const response = await fetch(`${API_BASE}/integrations/trackers/connection`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(input),
});
return handleResponse<ExternalTrackerConnectionRecord>(response);
},
introspectTracker: async (
input: Partial<ExternalTrackerConnectionInput> = { provider: 'mock' }
): Promise<ExternalTrackerSchema> => {
const response = await fetch(`${API_BASE}/integrations/trackers/introspect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(input),
});
return handleResponse<ExternalTrackerSchema>(response);
},
trackerSchema: async (): Promise<ExternalTrackerSchema> => {
const response = await fetch(`${API_BASE}/integrations/trackers/schema`, {
credentials: 'include',
});
return handleResponse<ExternalTrackerSchema>(response);
},
trackerProfiles: async (): Promise<ExternalTrackerMappingProfile[]> => {
const response = await fetch(`${API_BASE}/integrations/trackers/profiles`, {
credentials: 'include',
});
return handleResponse<ExternalTrackerMappingProfile[]>(response);
},
saveTrackerProfile: async (
profileId: string,
input: ExternalTrackerMappingProfileInput
): Promise<ExternalTrackerMappingProfile> => {
const response = await fetch(
`${API_BASE}/integrations/trackers/profiles/${encodeURIComponent(profileId)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(input),
}
);
return handleResponse<ExternalTrackerMappingProfile>(response);
},
validateTrackerProfile: async (profileId: string): Promise<ExternalTrackerValidationResult> => {
const response = await fetch(
`${API_BASE}/integrations/trackers/profiles/${encodeURIComponent(profileId)}/validate`,
{
method: 'POST',
credentials: 'include',
}
);
return handleResponse<ExternalTrackerValidationResult>(response);
},
dryRunTrackerCreate: async (
input: ExternalTrackerDryRunCreateInput
): Promise<ExternalTrackerDryRunCreateResult> => {
const response = await fetch(
`${API_BASE}/integrations/trackers/profiles/${encodeURIComponent(input.profileId)}/dry-run-create`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ taskId: input.taskId, task: input.task }),
}
);
return handleResponse<ExternalTrackerDryRunCreateResult>(response);
},
createTrackerWorkItem: async (
input: ExternalTrackerCreateWorkItemInput
): Promise<ExternalTrackerCreateWorkItemResult> => {
const response = await fetch(
`${API_BASE}/integrations/trackers/profiles/${encodeURIComponent(input.profileId)}/create`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
taskId: input.taskId,
task: input.task,
approvedBy: input.approvedBy,
}),
}
);
return handleResponse<ExternalTrackerCreateWorkItemResult>(response);
},
trackerAudits: async (limit = 25): Promise<ExternalTrackerSyncAudit[]> => {
const response = await fetch(`${API_BASE}/integrations/trackers/audits?limit=${limit}`, {
credentials: 'include',
});
return handleResponse<ExternalTrackerSyncAudit[]>(response);
},
};