mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
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:
parent
7950fec5f4
commit
5bd4377f32
16 changed files with 2734 additions and 11 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
150
server/src/__tests__/external-tracker-service.test.ts
Normal file
150
server/src/__tests__/external-tracker-service.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
223
server/src/__tests__/routes/external-trackers.test.ts
Normal file
223
server/src/__tests__/routes/external-trackers.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
209
server/src/routes/external-trackers.ts
Normal file
209
server/src/routes/external-trackers.ts
Normal 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 };
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
998
server/src/services/external-tracker-service.ts
Normal file
998
server/src/services/external-tracker-service.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
234
shared/src/types/external-tracker.types.ts
Normal file
234
shared/src/types/external-tracker.types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
164
web/src/__tests__/settings-trackers-mantine.test.tsx
Normal file
164
web/src/__tests__/settings-trackers-mantine.test.tsx
Normal 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' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
460
web/src/components/settings/tabs/TrackersTab.tsx
Normal file
460
web/src/components/settings/tabs/TrackersTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue