Skip to content

Transformation Engine

Transformation Engine: Unified Mapping & Normalisation Service

Section titled “Transformation Engine: Unified Mapping & Normalisation Service”

GuideMode normalises data from four integration providers (GitHub, Jira, Linear, Notion) into a unified internal model. This normalisation involves four types of mappings:

  • Labels (external labels to internal issue types)
  • Statuses (external workflow states to internal sub-statuses)
  • Environments (deployment environment names to production/staging/etc.)
  • Link types (external relationship types to internal link types)

While the core inference logic is already shared (mapLabelsToTypeAndBlocked, mapProviderStatusToSubStatus), the plumbing around it is duplicated across providers: loading mappings from the database, filtering by scope, applying fallbacks, and reprocessing when mappings change. AI-powered suggestions exist only for labels. The UX is organised per-provider rather than per-tenant, making it harder for teams that use multiple providers.

  1. Eliminate duplication by extracting a single transformation-engine service that all providers and webhooks use
  2. Extend AI suggestions to all mapping types (statuses, environments, link types)
  3. Shift the UX from per-provider to per-tenant with optional provider/repository overrides
  4. Enable parallel execution – each phase is independently deployable
Module Path Used by
Label inference utils/label-type-inference.ts All 4 providers
Status inference utils/status-sub-status-inference.ts All 4 providers
Link detection services/link-detection/ Generic service
Mapping preloader workflows/utils/mapping-loader.ts Workflow steps only
What Where Count
DB mapping queries GitHub issues, Linear issues, Linear webhook, Notion transform, label-reprocessing, status-reprocessing, mapping-loader 7
Environment matching (isProductionEnvironment, matchesPattern) services/github/deployments.ts + webhooks/handlers/github/deployments.ts 2 (identical copies)
Label-to-type wrappers GitHub issues, GitHub graphql/issues, Linear issues 3
Reprocessing engines services/label-reprocessing.ts + services/status-reprocessing.ts 2 (structurally identical)

Notion uses notion_database_mappings with a JSONB mappingConfig for column-level property mapping. This is architecturally different – it maps Notion database properties to issue fields, not values to values. However, Notion already falls through to the global label_mappings and status_mappings tables for value-level normalisation. The transformation engine handles this via bridge, not replacement.


services/transformation-engine/
index.ts -- Barrel exports
types.ts -- Shared interfaces
preloader.ts -- Unified mapping loader from DB
label-mapper.ts -- Thin facade over utils/label-type-inference.ts
status-mapper.ts -- Thin facade over utils/status-sub-status-inference.ts
environment-mapper.ts -- Extracted from github/deployments.ts
link-type-mapper.ts -- Extracted from jira/issues/transform.ts
reprocessor.ts -- Unified reprocessing (replaces 2 services)
ai-suggestions.ts -- Generalised AI suggestions (Phase 2)
  1. Thin facades, not rewrites – The existing utils/label-type-inference.ts and utils/status-sub-status-inference.ts remain untouched. The engine wraps them with consistent scope filtering.

  2. Notion bridged, not forced – Notion’s embedded valueMappings are checked first as a Notion-specific optimisation. If not found, fall through to global mapping tables via the engine.

  3. Preload for batch, fresh for webhooks – Sync workflows call preloadAllMappings() once. Webhook handlers call individual loaders per event.

  4. Provider callbacks for fallback – Jira falls back to issue type inference, GitHub uses stateReason. The facades accept optional providerFallback callbacks rather than embedding provider logic.

  5. Tenant-first scoping – The new model treats mappings as tenant-level defaults with optional provider, repository, and project overrides.


Goal: Consolidate all duplicated mapping logic into services/transformation-engine/ without changing any behaviour.

Can run in parallel with: Nothing (foundation phase) Prerequisite for: Phase 2, Phase 3, Phase 4

Create the type definitions that all other modules in the engine will use.

Create: services/transformation-engine/types.ts

Provider = 'github' | 'jira' | 'linear' | 'notion'
MappingScope {
repositoryId?: string
projectId?: string // GitHub Projects v2
}
LabelMapping {
externalLabel: string
internalType: IssueType | null
mapsToBlocked: boolean
repositoryId: string | null
}
StatusMapping {
externalStatus: string
internalSubStatus: SubStatus
projectId: string | null
repositoryId: string | null
}
EnvironmentMapping {
environmentPattern: string
isProduction: boolean
repositoryId: string | null
}
LinkTypeMapping {
externalLinkType: string
internalLinkType: InternalLinkType
requiresSourceType: IssueType | null
requiresTargetType: IssueType | null
repositoryId: string | null
}
PreloadedMappings {
labels: LabelMapping[]
statuses: StatusMapping[]
environments: EnvironmentMapping[]
linkTypes: LinkTypeMapping[]
}
ReprocessResult<TChange> {
issuesProcessed: number
issuesUpdated: number
changes: TChange[]
factTablesRefreshed: boolean
}

Create: services/transformation-engine/index.ts – barrel exports

Consolidate all 7 duplicate mapping-loading patterns into a single module.

Create: services/transformation-engine/preloader.ts

Functions to implement:

  • preloadLabelMappings(db, tenantId, provider) – query label_mappings table filtered by tenant+provider
  • preloadStatusMappings(db, tenantId, provider) – query status_mappings table filtered by tenant+provider
  • preloadEnvironmentMappings(db, tenantId, provider) – query environment_mappings table filtered by tenant+provider (new – currently only queried inline in github/deployments.ts)
  • preloadLinkTypeMappings(db, tenantId, provider) – query link_type_mappings table filtered by tenant+provider (new – currently only queried inline in Jira transform)
  • preloadAllMappings(db, tenantId, provider) – loads all 4 types in parallel via Promise.all
  • getLabelMappingsForScope(mappings, scope) – filters preloaded labels by repository (repo-specific + global)
  • getStatusMappingsForScope(mappings, scope) – filters preloaded statuses by project/repository (most-specific-wins priority)
  • getEnvironmentMappingsForScope(mappings, scope) – filters preloaded environments by repository
  • getLinkTypeMappingsForScope(mappings, scope) – filters preloaded link types by repository

The scope-filtering functions preserve the existing priority hierarchy:

  1. Project + Repository specific (most specific)
  2. Project specific
  3. Repository specific
  4. Global (least specific)

Modify: workflows/utils/mapping-loader.ts – make it a thin re-export shim:

export { preloadLabelMappings, preloadStatusMappings, preloadAllMappings, ... } from '../../services/transformation-engine/preloader.js'

This ensures all existing workflow code continues working without import changes.

Source code to consolidate from:

  • workflows/utils/mapping-loader.ts lines 60-230 (primary source)
  • services/label-reprocessing.ts lines 42-70 (loadMappingsWithDefault)
  • services/status-reprocessing.ts lines 46-67 (loadStatusMappings)
  • services/github/deployments.ts lines 63-78 (inline env mapping query)
  • services/linear/issues.ts lines 45-73 (getLinearLabelMappings)
  • webhooks/handlers/linear/issue.ts lines 30-97 (duplicate of Linear sync loaders)
  • services/notion/issues/transform.ts lines 186-204 (inline label mapping query)

Extract the duplicated environment matching logic into a standalone module.

Create: services/transformation-engine/environment-mapper.ts

Functions to implement:

  • matchesPattern(value, pattern) – wildcard matching (* = any, ? = single char). Currently at services/github/deployments.ts:106-109
  • isDefaultProductionEnvironment(environmentName) – hardcoded fallback heuristic (prod, production, prod-*). Currently at services/github/deployments.ts:101-104
  • isProductionEnvironment(environmentName, mappings, scope)pure function (no DB access). Takes pre-loaded environment mappings, filters by scope, applies pattern matching, falls back to default heuristic. Currently at services/github/deployments.ts:63-99 but does its own DB query – the new version is pure.
  • mapEnvironmentCategory(environmentName) – maps to production/staging/development/qa/preview/other. Currently at services/github/deployments.ts:111-133

Modify: services/github/deployments.ts

  • Remove the 4 local functions (isProductionEnvironment, isDefaultProductionEnvironment, matchesPattern, mapEnvironment)
  • Import from transformation engine
  • Update upsertGitHubDeployment to preload environment mappings via the engine preloader, then call the pure isProductionEnvironment

Modify: webhooks/handlers/github/deployments.ts

  • Remove the identical 4 local functions
  • Import from transformation engine

Create thin facade functions that handle scope filtering before delegating to the existing shared utilities.

Create: services/transformation-engine/label-mapper.ts

mapLabels(
labels: string[],
mappings: PreloadedMappings,
scope: MappingScope,
provider: Provider,
options?: {
defaultType?: IssueType
providerTypeInference?: (labels: string[]) => IssueType | null
}
): LabelMappingResult
// Internally:
// 1. Filter mappings.labels by scope (repo-specific + global)
// 2. If providerTypeInference provided (Jira), try it as additional fallback
// 3. Delegate to mapLabelsToTypeAndBlocked() from utils/label-type-inference.ts

Create: services/transformation-engine/status-mapper.ts

mapStatus(
provider: Provider,
nativeStatus: string,
state: IssueState,
mappings: PreloadedMappings,
scope: MappingScope
): SubStatusResult
// Internally:
// 1. Filter mappings.statuses by scope (project+repo priority)
// 2. Delegate to mapProviderStatusToSubStatus() from utils/status-sub-status-inference.ts

These facades eliminate the 3+ duplicate wrapper functions in GitHub issues, GitHub graphql/issues, and Linear issues.

Modify: services/github/issues.ts – remove mapLabelsToTypeAndBlockedSync(), import from engine Modify: services/github/graphql/issues.ts – remove mapLabelsToTypeAndBlockedSync(), import from engine Modify: services/linear/issues.ts – remove mapLinearLabelsToTypeAndBlocked(), import from engine

Extract the Jira link type mapping into a provider-agnostic module.

Create: services/transformation-engine/link-type-mapper.ts

mapLinkType(
externalLinkType: string,
direction: 'outward' | 'inward',
sourceType: IssueType,
targetType: IssueType,
mappings: PreloadedMappings,
scope: MappingScope
): InternalLinkType | null
// Internally:
// 1. Filter mappings.linkTypes by scope
// 2. Check custom mappings (with conditional type requirements)
// 3. Check keyword-based inference (validates, blocks, duplicates, etc.)
// 4. Issue type inference (discovery + feature = validates)
// 5. Standard defaults

This is a direct extraction of mapJiraLinkTypeToInternal() from services/jira/issues/transform.ts:294-345, generalised to work with any provider.

Modify: services/jira/issues/transform.ts – remove mapJiraLinkTypeToInternal(), import from engine

Merge the two structurally identical reprocessing services into a single generic engine.

Create: services/transformation-engine/reprocessor.ts

The two existing reprocessors follow the same pattern:

  1. Load mappings from DB
  2. Load all issues for tenant+provider
  3. For each issue, compute new values using current mappings
  4. Diff against existing values
  5. Batch update changed issues (500 per batch)
  6. Refresh fact tables

The unified version:

reprocessLabels(db, tenantId, provider, options?: { dryRun?, limit? }): ReprocessResult<LabelChange>
reprocessStatuses(db, tenantId, provider, options?: { dryRun?, limit? }): ReprocessResult<StatusChange>

Both functions:

  1. Use the engine preloader to load mappings
  2. Query issues with the relevant data (labels or native statuses)
  3. Apply the engine’s mapper facades to compute new values
  4. Diff and batch update
  5. Refresh fact tables via refreshFactTablesForTenant()

The status reprocessor currently only handles GitHub (line 171: if (provider === 'github')). The unified version extends this to all providers by using mapProviderStatusToSubStatus() which already supports all 4 providers.

Modify: routes/settings/work-tracking.ts – update reprocessing endpoints to call unified reprocessor Delete: services/label-reprocessing.ts Delete: services/status-reprocessing.ts

Update each provider to import from the transformation engine instead of maintaining local copies.

GitHub (services/github/issues.ts, services/github/graphql/issues.ts):

  • Remove local preloadLabelMappings(), preloadStatusMappings() calls
  • Remove local mapLabelsToTypeAndBlockedSync() wrapper
  • Import preloadAllMappings, mapLabels, mapStatus from engine
  • Pass preloaded mappings through to upsert functions

Jira (services/jira/issues/sync.ts, services/jira/issues/transform.ts):

  • Remove inline status mapping query
  • Remove mapJiraLinkTypeToInternal()
  • Import from engine
  • Pass preloaded mappings and Jira-specific providerTypeInference callback (for inferTypeFromJiraIssueType)

Linear (services/linear/issues.ts, webhooks/handlers/linear/issue.ts):

  • Remove getLinearLabelMappings(), getLinearStatusMappings()
  • Remove mapLinearLabelsToTypeAndBlocked()
  • Import from engine

Notion (services/notion/issues/transform.ts):

  • Remove inline label mapping DB query (lines 186-204)
  • Import preloadLabelMappings from engine
  • Keep Notion’s embedded valueMappings check as-is (bridge pattern)

Workflows (all 4 workflow files):

  • No changes needed – they import from workflows/utils/mapping-loader.ts which becomes a re-export shim
  • Unit tests for preloader.ts scope-filtering functions (mock DB)
  • Unit tests for environment-mapper.ts (wildcard matching, priority, fallback)
  • Unit tests for link-type-mapper.ts (custom mappings, conditional types, defaults)
  • Verify existing tests still pass: npm run test
  • Typecheck: npm run typecheck
  • Lint: npm run lint
  • Build: npm run build
File Action
services/transformation-engine/types.ts Create
services/transformation-engine/index.ts Create
services/transformation-engine/preloader.ts Create
services/transformation-engine/environment-mapper.ts Create
services/transformation-engine/label-mapper.ts Create
services/transformation-engine/status-mapper.ts Create
services/transformation-engine/link-type-mapper.ts Create
services/transformation-engine/reprocessor.ts Create
workflows/utils/mapping-loader.ts Simplify to re-export shim
services/github/deployments.ts Remove env functions, import from engine
services/github/issues.ts Remove preload/wrapper, import from engine
services/github/graphql/issues.ts Remove label wrapper, import from engine
services/linear/issues.ts Remove preload/wrapper, import from engine
services/jira/issues/transform.ts Remove link mapper, import from engine
services/jira/issues/sync.ts Use engine preloader
services/notion/issues/transform.ts Use engine preloader for global mappings
webhooks/handlers/github/deployments.ts Import env matching from engine
webhooks/handlers/linear/issue.ts Remove preload functions, import from engine
routes/settings/work-tracking.ts Use unified reprocessor
services/label-reprocessing.ts Delete
services/status-reprocessing.ts Delete

Goal: Generalise the AI-powered suggestion system from labels-only to all mapping types.

Can run in parallel with: Phase 3 (UX shift) Prerequisite: Phase 1 (needs the unified preloader and types)

Create: services/transformation-engine/ai-suggestions.ts

Refactor from services/label-mapping-ai.ts into a generalised suggestion engine.

Design: A single suggestMappings() function that accepts a mapping type and a list of external values:

MappingType = 'labels' | 'statuses' | 'environments' | 'link_types'
suggestMappings(
type: MappingType,
externalValues: string[],
config: AIConfig,
context?: {
provider?: Provider
existingMappings?: PreloadedMappings // So AI knows what's already mapped
}
): Promise<SuggestionResult>
SuggestionResult {
suggestions: MappingSuggestion[]
usage: { promptTokens, completionTokens, totalTokens }
}
MappingSuggestion {
externalValue: string
suggestedMapping: string // The internal type/subStatus/etc.
suggestsBlocked?: boolean // For labels only
confidence: 'high' | 'medium' | 'low'
reasoning: string
}

Each mapping type needs its own section in the system prompt, describing:

  • Available internal values and their meanings
  • Default heuristic patterns (so the AI focuses on non-obvious mappings)
  • Examples of good mappings

Label suggestions (exists today in label-mapping-ai.ts):

  • 7 issue types with descriptions and examples
  • Blocked status detection
  • Default heuristic patterns to skip

Status suggestions (new):

  • 9 sub-statuses with descriptions: backlog, ready, discovery, delivery, review, blocked, parked, done, canceled
  • Per-provider heuristic patterns (from status-sub-status-inference.ts) so the AI knows what’s already handled
  • Context: the AI sees the provider name to understand the status vocabulary

Environment suggestions (new):

  • Binary classification: production vs non-production
  • Plus environment category: production, staging, development, qa, preview, other
  • Default patterns to skip: prod, production, staging, dev, etc.
  • Focus on non-obvious patterns: release-*, canary, custom naming conventions

Link type suggestions (new):

  • 8 internal link types with descriptions
  • Direction awareness (outward vs inward)
  • Conditional type requirements explanation

Modify: routes/settings/work-tracking.ts

Existing endpoint:

  • POST /label-mappings/suggestions – keep but delegate to generalised engine

New endpoints:

  • POST /status-mappings/suggestions – AI suggestions for unmapped statuses
  • POST /environment-mappings/suggestions – AI suggestions for environment patterns
  • POST /link-mappings/suggestions – AI suggestions for link types

Each endpoint:

  1. Loads existing mappings via the engine preloader
  2. Filters to unmapped values
  3. Calls suggestMappings() with the appropriate type
  4. Returns suggestions with confidence and reasoning

Add an endpoint to apply AI suggestions in bulk:

  • POST /mappings/apply-suggestions – accepts an array of suggestions, creates mappings

This enables a “review and apply” workflow: AI suggests, user reviews, user applies selected suggestions.

File Action
services/transformation-engine/ai-suggestions.ts Create
routes/settings/work-tracking.ts Add 3 new suggestion endpoints
services/label-mapping-ai.ts Delete (logic moved to ai-suggestions.ts)

Phase 3: Tenant-First UX — New “Mappings” Settings Section

Section titled “Phase 3: Tenant-First UX — New “Mappings” Settings Section”

Goal: Create a new top-level “Mappings” settings section with one dedicated page per mapping type. Shift the mental model from “configure per provider” to “configure for your team, with optional provider/repository overrides”. This is a critical user-facing feature — usability is paramount.

Can run in parallel with: Phase 2 (AI suggestions) Prerequisite: Phase 1 (needs the unified preloader and types)

Today, mapping configuration is buried inside each provider’s settings page as tabs. A user who uses GitHub, Jira, and Linear must navigate to three different settings pages to configure what is conceptually the same thing. Worse, link mappings live in a completely separate page. There is no way to set a tenant-wide default — everything must be configured per-provider.

This creates three problems:

  1. Discoverability — new users don’t know mapping configuration exists until they notice wrong issue types
  2. Duplication — the same “bug” → bug mapping must be created in GitHub, Jira, and Linear separately
  3. Cognitive load — the user must understand the provider settings before understanding the mapping concept

Mappings are scattered across provider settings and one orphaned page:

Settings sidebar:
Integrations:
├── GitHub → tabs: Setup | Org Sync | Repo Sync | Labels | Statuses | Environments
├── Jira → tabs: Setup | Org Sync | Repo Sync | Labels | Statuses
├── Linear → tabs: Setup | Org Sync | Repo Sync | Labels | Statuses
├── Notion → tabs: Setup | Database Mappings (column-level, unique)
└── Link Mappings (separate page, all providers)
  • Label, status, and environment mappings are tabs inside each provider
  • Link mappings are a standalone page under Integrations
  • No tenant-level defaults exist
  • No way to see all mappings across providers at once

A new “Mappings” section in settings with four dedicated pages — one per mapping type:

Settings sidebar:
Integrations:
├── GitHub → tabs: Setup | Org Sync | Repo Sync (mappings tabs REMOVED)
├── Jira → tabs: Setup | Org Sync | Repo Sync
├── Linear → tabs: Setup | Org Sync | Repo Sync
└── Notion → tabs: Setup | Database Mappings (stays — it's column-level config)
Mappings: ← NEW SECTION
├── Labels ← dedicated page
├── Statuses ← dedicated page
├── Environments ← dedicated page
└── Relationships ← dedicated page (renamed from "Link Mappings")

Each page shows mappings across all providers with scope-based filtering and the ability to create tenant-wide defaults.

Modify: db/schema/work-tracking-config.ts

Make provider nullable on all four mapping tables:

  • labelMappings.providervarchar(50).notNull() becomes varchar(50) (nullable)
  • statusMappings.provider — same
  • environmentMappings.provider — same
  • linkTypeMappings.provider — same

When provider is NULL, the mapping applies to all providers (tenant-level default).

Generate migration: npm run db:generate

This is backward compatible — existing mappings retain their provider value and continue to work identically. New mappings can omit provider to apply globally.

Resolution hierarchy (most specific wins):

  1. Provider + repository/project specific
  2. Provider + global (no repo/project)
  3. Tenant default + repository specific (provider = NULL)
  4. Tenant default + global (provider = NULL, repositoryId = NULL)

Modify: services/transformation-engine/preloader.ts

Update the preloader to support the new scoping:

preloadLabelMappings(db, tenantId, provider?)
— If provider specified: load both provider-specific AND tenant defaults (provider=NULL)
— If provider not specified: load all mappings for tenant

The scope-filtering functions gain a provider dimension:

  1. Provider + repository specific
  2. Provider + global
  3. Tenant default (provider=NULL) + repository specific
  4. Tenant default (provider=NULL) + global

Step 3.3: Navigation — new “Mappings” section

Section titled “Step 3.3: Navigation — new “Mappings” section”

Modify: ui/pages/Settings/components/SettingsNav.tsx

Add a new nav section between “Organisation” and “Integrations”:

{
label: 'Mappings',
items: [
{ path: '/settings/mappings/labels', label: 'Labels', icon: '...' },
{ path: '/settings/mappings/statuses', label: 'Statuses', icon: '...' },
{ path: '/settings/mappings/environments', label: 'Environments', icon: '...' },
{ path: '/settings/mappings/relationships', label: 'Relationships', icon: '...' },
]
}

Remove: The Link Mappings item from the Integrations section (replaced by Relationships).

Modify: ui/pages/Settings/index.tsx

Add routes for the four new pages:

/settings/mappings/labels → LabelMappingsPage
/settings/mappings/statuses → StatusMappingsPage
/settings/mappings/environments → EnvironmentMappingsPage
/settings/mappings/relationships → RelationshipMappingsPage

Add redirects from old URLs:

/settings/link-mappings → /settings/mappings/relationships
/settings/github?tab=label-mappings → /settings/mappings/labels?provider=github

Remove mapping tabs from provider settings pages (GitHubSettings, JiraSettings, LinearSettings) but keep Notion’s database mapping tabs (they are column-level config, not value-level mapping).

Step 3.4: Page design — Label Mappings page

Section titled “Step 3.4: Page design — Label Mappings page”

Create: ui/pages/Settings/mappings/LabelMappingsPage.tsx

This is the reference design that the other three pages follow.

┌─────────────────────────────────────────────────────────────────┐
│ Labels │
│ Map external labels to internal issue types. Tenant-wide │
│ defaults apply to all providers unless overridden. │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ Filters ──────────────────────────────────────────────────┐ │
│ │ Provider: [All Providers ▾] Repository: [All ▾] [Reset] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Actions ──────────────────────────────────────────────────┐ │
│ │ [+ Add Mapping] [AI Suggest] [Re-process Issues] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Create Form (appears when Add clicked) ───────────────────┐ │
│ │ Label Pattern: [________] Maps To: [Feature ▾] │ │
│ │ Sets Blocked: [ ] Scope: [All Providers ▾] [All Repos ▾] │ │
│ │ [Create] [Cancel] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Unmapped Labels Panel (collapsible) ──────────────────────┐ │
│ │ 12 unmapped labels from your synced issues │ │
│ │ [priority] [design] [ux] [frontend] ... [Map Selected] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Active Mappings ──────────────────────────────────────────┐ │
│ │ │ │
│ │ ── Tenant Defaults ───────────────────────────────────── │ │
│ │ │ Pattern │ Maps To │ Blocked │ Actions │ │
│ │ │ bug │ Bug │ │ [Edit] [Delete] │ │
│ │ │ enhancement │ Feature │ │ [Edit] [Delete] │ │
│ │ │ blocked │ — │ ✓ │ [Edit] [Delete] │ │
│ │ │ │
│ │ ── GitHub Overrides ──────────────────────────────────── │ │
│ │ │ good first │ Chore │ │ [Edit] [Delete] │ │
│ │ │ │
│ │ ── Jira Overrides ────────────────────────────────────── │ │
│ │ │ (none — using tenant defaults) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Default Heuristics (collapsible reference) ───────────────┐ │
│ │ These patterns are detected automatically when no custom │ │
│ │ mapping exists. Create a mapping to override them. │ │
│ │ Pattern │ Auto-detected as │ │
│ │ *bug*, *defect* │ Bug │ │
│ │ *feature*, ... │ Feature │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

Grouped by scope, not flat table: Mappings are visually grouped into “Tenant Defaults”, “GitHub Overrides”, “Jira Overrides”, etc. This makes the inheritance model immediately visible. Empty provider groups show “(none — using tenant defaults)” to reinforce that defaults cascade.

Scope selector in the create form: When adding a new mapping, the user chooses scope via two dropdowns:

  • Provider: “All Providers” (tenant default) | GitHub | Jira | Linear | Notion
  • Repository: “All Repositories” | specific repo (only shown if provider selected)

“All Providers” is the default and recommended choice — this nudges users toward tenant defaults.

Unmapped labels panel: Shows labels from synced issues that have no custom mapping. These are the “quick wins” — the user can click a label to pre-fill the create form. When AI suggestions are available (Phase 2), the “AI Suggest” button analyses these unmapped labels.

Filter bar: Provider and repository filters narrow the view. When filtering to a specific provider, the “Tenant Defaults” section still shows (dimmed) to indicate what the provider inherits. This prevents the confusing situation where a user filters to “GitHub”, creates a mapping, and doesn’t understand why it only applies to GitHub.

Inline editing: Clicking “Edit” on a mapping row opens inline editing (same pattern as the current LabelMappingsTable). Changes save immediately with optimistic UI.

Reprocess with preview: “Re-process Issues” button opens a confirmation modal showing impact count from a dry-run. E.g., “This will update 142 issues across 3 providers. Proceed?”

Step 3.5: Page design — Status Mappings page

Section titled “Step 3.5: Page design — Status Mappings page”

Create: ui/pages/Settings/mappings/StatusMappingsPage.tsx

Same layout pattern as labels but with status-specific features:

  • Maps To dropdown shows the 9 sub-statuses: backlog, ready, discovery, delivery, review, blocked, parked, done, canceled
  • WIP badge: Each mapping shows whether the sub-status excludes from WIP (work in progress) as a subtle badge
  • Project scope: For GitHub, an additional “Project” scope dropdown appears (for GitHub Projects v2 custom status fields)
  • Available statuses panel: Shows unique nativeStatus values from synced issues with counts, similar to the existing AvailableStatusesPanel
  • Grouped by scope: Same tenant defaults → provider overrides → repo overrides grouping

Status mappings have a richer “how it works” reference section:

┌─ How Status Mapping Works ─────────────────────────────────┐
│ │
│ External Status → Sub-Status → State (derived) │
│ │
│ "In Progress" → delivery → in_progress │
│ "Done" → done → closed │
│ "Backlog" → backlog → open │
│ │
│ Sub-statuses that exclude from WIP: │
│ [backlog] [ready] [blocked] [parked] [done] [canceled] │
│ │
│ Active WIP sub-statuses: │
│ [discovery] [delivery] [review] │
└─────────────────────────────────────────────────────────────┘

Step 3.6: Page design — Environment Mappings page

Section titled “Step 3.6: Page design — Environment Mappings page”

Create: ui/pages/Settings/mappings/EnvironmentMappingsPage.tsx

Simpler than labels/statuses since environment mappings are binary (production or not).

  • Pattern field: Supports wildcards (* and ?). Input shows hint: “e.g., prod*, staging, release-*”
  • Is Production toggle: Simple on/off toggle
  • Repository scope: Optional per-repository override
  • Provider scope: Optional per-provider override (though today only GitHub has deployments, this supports future Jira/Linear deployment integration)
  • No “unmapped” panel: Instead, show a “Detected environments” panel listing unique environment names from synced deployments with their current classification (auto-detected vs custom mapped)
┌─ Detected Environments ────────────────────────────────────┐
│ Environment │ Deployments │ Classification │
│ production │ 847 │ ✓ Production (auto) │
│ staging │ 312 │ Staging (auto) │
│ preview-pr-* │ 1,204 │ Preview (auto) │
│ canary │ 56 │ ⚠ Other (unmapped) │
│ release-candidate │ 23 │ ⚠ Other (unmapped) │
│ │
│ ⚠ 2 environments may need mapping [Map them →] │
└─────────────────────────────────────────────────────────────┘

Step 3.7: Page design — Relationship Mappings page

Section titled “Step 3.7: Page design — Relationship Mappings page”

Create: ui/pages/Settings/mappings/RelationshipMappingsPage.tsx

Renamed from “Link Mappings” to “Relationships” for clarity. Uses a card-based layout (matching the existing LinkMappingsSettings pattern) rather than a table, because relationship mappings have richer structure (conditional source/target type).

  • External type: Text input for the provider’s relationship name (e.g., “Blocks”, “Relates”, “Implements”)
  • Internal type: Dropdown of 8 internal types: validates, invalidates, implements, blocks, blocked_by, relates_to, duplicates, duplicated_by
  • Conditional filters (optional): “Only when source is [type]” and “Only when target is [type]”
  • Provider scope: Required (since relationship names are provider-specific by nature). But “All Providers” option exists for generic names like “blocks”.
┌─ Relationship Mapping Card ────────────────────────────────┐
│ │
│ [Jira] "Relates" ──→ validates │
│ Only when: source is Discovery AND target is Feature │
│ [Edit] [×] │
│ │
│ [All] "Blocks" ──→ blocks │
│ (applies to all providers) │
│ [Edit] [×] │
└─────────────────────────────────────────────────────────────┘

The card layout handles the conditional logic more gracefully than a table row — conditions are shown as a secondary line beneath the mapping.

Create: ui/components/mappings/MappingScopeSelector.tsx

Reusable scope selector used by all four pages:

[Provider ▾: All Providers] [Repository ▾: All Repositories] [Project ▾: All Projects]
  • Provider dropdown: “All Providers” + connected providers (from synced data)
  • Repository dropdown: shown only when a specific provider is selected. Lists repositories for that provider.
  • Project dropdown: shown only for GitHub when status mapping. Lists GitHub Projects v2.
  • Each dropdown shows counts: “GitHub (142 issues)” to give context

Create: ui/components/mappings/MappingScopeGroupedList.tsx

Generic component that renders mappings grouped by scope:

── Tenant Defaults ───────────────────────────
(mapping rows)
── GitHub Overrides ──────────────────────────
(mapping rows or "using tenant defaults")
── Jira Overrides ────────────────────────────
(mapping rows or "using tenant defaults")

Accepts renderRow prop for type-specific rendering (labels show blocked toggle, statuses show WIP badge, etc.)

Create: ui/components/mappings/UnmappedValuesPanel.tsx

Generic “unmapped values” panel used by labels and statuses pages. Shows values from synced issues that have no custom mapping, with quick-add actions.

Create: ui/components/mappings/HeuristicsReference.tsx

Collapsible reference section showing default heuristic patterns. Currently duplicated across LabelMappingsTable and StatusMappingsTable — extract into a shared component that accepts a heuristics array.

Modify: routes/settings/work-tracking.ts

Update all CRUD endpoints to support provider = null:

  • POST /label-mappingsprovider becomes optional in the request body. When omitted, creates a tenant-level default.
  • Same for status, environment, and link type mappings.
  • GET endpoints gain a scope query param: ?scope=all (default), ?scope=global (tenant defaults only), ?scope=provider:github

New endpoint: GET /mappings/summary — returns a summary of all mapping counts by type and scope, used by the nav section to show badge counts (e.g., “Labels (12)” or a warning dot for unmapped values).

Step 3.10: Remove mapping tabs from provider pages

Section titled “Step 3.10: Remove mapping tabs from provider pages”

Modify: ui/pages/Settings/GitHubSettings.tsx

  • Remove tabs: label-mappings, status-mappings, environment-mappings
  • Keep tabs: setup, org-sync, repository-sync
  • Add a “Configure Mappings” link card pointing to /settings/mappings/labels?provider=github

Modify: ui/pages/Settings/JiraSettings.tsx

  • Remove mapping tabs, add “Configure Mappings” link

Modify: ui/pages/Settings/LinearSettings.tsx

  • Remove mapping tabs, add “Configure Mappings” link

Keep unchanged: ui/pages/Settings/NotionSettings.tsx

  • Notion’s “Database Mappings” tab stays — it’s column-level property-to-field mapping, not value-level transformation

Create: ui/components/mappings/MigrationWizard.tsx

A one-time tool for existing tenants to consolidate their per-provider mappings.

Shown as a dismissible banner at the top of any mapping page when duplicates are detected:

┌─ Consolidate Mappings ──────────────────────────────────────┐
│ ℹ We detected 8 mappings that are identical across multiple │
│ providers. Consolidate them into tenant defaults? │
│ [Review & Consolidate] [×]│
└──────────────────────────────────────────────────────────────┘

Clicking “Review & Consolidate” opens a modal:

┌─ Consolidate Duplicate Mappings ────────────────────────────┐
│ │
│ These mappings are identical across all your providers: │
│ │
│ ☑ "bug" → Bug │
│ Currently in: GitHub, Jira, Linear │
│ → Will become: Tenant default │
│ │
│ ☑ "enhancement" → Feature │
│ Currently in: GitHub, Linear │
│ → Will become: Tenant default │
│ │
│ ☐ "In Progress" → delivery │
│ Currently in: GitHub, Jira │
│ Note: Linear uses "Started" for the same thing │
│ → Will become: Tenant default │
│ │
│ [Consolidate Selected (6)] [Cancel] │
└──────────────────────────────────────────────────────────────┘

The wizard:

  1. Detects duplicate mappings across providers (same external value, same internal mapping)
  2. Shows them grouped with provider badges
  3. User selects which to consolidate
  4. On confirm: creates tenant defaults, deletes the per-provider duplicates
  5. Dismisses the banner (stored in user settings so it doesn’t reappear)
Section titled “Step 3.12: Deep-link support from provider pages”

When a user lands on a provider settings page (e.g., GitHub), the removed mapping tabs should gracefully redirect:

  • /settings/github?tab=label-mappings/settings/mappings/labels?provider=github
  • /settings/github?tab=status-mappings/settings/mappings/statuses?provider=github
  • /settings/github?tab=environment-mappings/settings/mappings/environments?provider=github
  • /settings/link-mappings/settings/mappings/relationships

The provider query param pre-selects the provider filter on the mapping page, so the user sees their provider’s mappings immediately.

Modify: ui/hooks/useWorkTracking.ts

Update the existing hooks to support the new scoping:

  • useLabelMappings(filters?: { provider?, scope? }) — add optional scope filter
  • useCreateLabelMapping() — mutation body now accepts optional provider (null = tenant default)
  • Same pattern for status, environment mappings

Modify: ui/hooks/useLinkMappings.ts

  • Same updates: optional provider in create, scope-aware listing

New hook: useMappingSummary() — fetches /mappings/summary for nav badges and the migration wizard duplicate detection.

File Action
Schema & API
db/schema/work-tracking-config.ts Make provider nullable on all mapping tables
db/migrations/XXXX_tenant_default_mappings.ts Generated migration
services/transformation-engine/preloader.ts Update to handle provider=NULL
services/transformation-engine/types.ts Update MappingScope to include optional provider
routes/settings/work-tracking.ts Update CRUD for optional provider, add /mappings/summary
New Pages
ui/pages/Settings/mappings/LabelMappingsPage.tsx Create — primary reference page
ui/pages/Settings/mappings/StatusMappingsPage.tsx Create — with WIP badges and project scope
ui/pages/Settings/mappings/EnvironmentMappingsPage.tsx Create — with detected environments panel
ui/pages/Settings/mappings/RelationshipMappingsPage.tsx Create — card layout with conditional filters
Shared Components
ui/components/mappings/MappingScopeSelector.tsx Create — reusable provider/repo/project scope picker
ui/components/mappings/MappingScopeGroupedList.tsx Create — grouped-by-scope rendering
ui/components/mappings/UnmappedValuesPanel.tsx Create — quick-add for unmapped labels/statuses
ui/components/mappings/HeuristicsReference.tsx Create — collapsible default patterns reference
ui/components/mappings/MigrationWizard.tsx Create — consolidation wizard with duplicate detection
Navigation & Routing
ui/pages/Settings/components/SettingsNav.tsx Add “Mappings” section, remove Link Mappings from Integrations
ui/pages/Settings/index.tsx Add 4 new routes, add redirects from old URLs
Provider Pages
ui/pages/Settings/GitHubSettings.tsx Remove mapping tabs, add “Configure Mappings” link
ui/pages/Settings/JiraSettings.tsx Remove mapping tabs, add “Configure Mappings” link
ui/pages/Settings/LinearSettings.tsx Remove mapping tabs, add “Configure Mappings” link
ui/pages/Settings/LinkMappingsSettings.tsx Delete (replaced by RelationshipMappingsPage)
Hooks
ui/hooks/useWorkTracking.ts Update for optional provider scoping
ui/hooks/useLinkMappings.ts Update for optional provider scoping
ui/hooks/useMappingSummary.ts Create — summary counts and duplicate detection
Legacy Components (can be deleted after migration)
ui/components/work-tracking/LabelMappingsTable.tsx Delete (logic moved to LabelMappingsPage)
ui/components/work-tracking/StatusMappingsTable.tsx Delete (logic moved to StatusMappingsPage)
ui/components/work-tracking/EnvironmentMappingsTable.tsx Delete (logic moved to EnvironmentMappingsPage)

Goal: Build on the unified engine for power-user features.

Can run in parallel with: Nothing directly (depends on Phase 1-3) Prerequisite: Phase 1, Phase 2, Phase 3

Pre-built mapping sets for common configurations:

  • “Standard GitHub” – maps common GitHub label conventions
  • “Standard Jira” – maps common Jira workflow statuses
  • “DORA Metrics” – optimised environment and status mappings for DORA tracking
  • “Custom” – blank slate

Templates stored as JSON, applied via a “Start from template” action in the UI.

Before applying mappings (or reprocessing), show a preview:

Preview: 142 issues would change
87 issues: type "other" → "feature" (label "enhancement")
34 issues: type "other" → "chore" (label "maintenance")
21 issues: subStatus null → "review" (status "In Review")

This uses the reprocessor’s dry-run mode (already exists) with a richer UI.

Step 4.3: Cross-provider mapping intelligence

Section titled “Step 4.3: Cross-provider mapping intelligence”

The AI suggestion engine learns from mappings across all providers in a tenant:

  • “You mapped ‘In Progress’ → delivery for GitHub. Jira has ‘In Development’ which is unmapped. Suggest: delivery?”
  • “Linear’s ‘Started’ maps to delivery in 90% of tenants. Suggest the same?”

This requires tenant-level aggregation but no schema changes.

Export all mappings as JSON/CSV. Import to replicate configuration across tenants.

GET /api/settings/mappings/export?format=json
POST /api/settings/mappings/import
File Action
services/transformation-engine/templates.ts Create template definitions
ui/components/mappings/MappingPreview.tsx Create preview component
ui/components/mappings/TemplateSelector.tsx Create template selector
routes/settings/work-tracking.ts Add export/import endpoints

Phase 1 (Extract & Unify)
├──► Phase 2 (AI Suggestions) ◄──── can run in parallel ────► Phase 3 (Tenant-First UX)
│ │ │
│ └──────────────────► Phase 4 (Advanced) ◄─────────────────┘
  • Phase 1 is the foundation – must complete first
  • Phase 2 and Phase 3 are independent and can run in parallel
  • Phase 4 depends on all three previous phases

Risk Impact Mitigation
Breaking existing sync during Phase 1 migration High Migrate one provider at a time. Keep old imports as re-export shims. Run full test suite after each provider.
Notion mapping incompatibility Medium Bridge pattern: Notion checks embedded config first, falls through to engine. No changes to Notion’s column-mapping concept.
Schema migration for nullable provider (Phase 3) Medium Backward compatible: existing rows keep their provider value. New rows can omit it. No data migration needed.
AI suggestion quality for statuses/environments Low Include existing heuristic patterns in system prompt so AI skips obvious ones. Require user review before applying.
Performance regression from extra abstraction Low Facades are thin (zero overhead). Preloader already does parallel loading.

  1. npm run typecheck – zero errors
  2. npm run lint – zero errors
  3. npm run build – succeeds
  4. npm run test – all existing tests pass
  5. Manual: trigger GitHub sync – label/status mappings applied correctly
  6. Manual: trigger Jira/Linear/Notion sync – mappings applied correctly
  7. Manual: change a label mapping, trigger reprocessing – issues updated correctly
  8. Manual: GitHub deployment webhook – environment mapping resolves correctly
  1. All Phase 1 checks pass
  2. Manual: request AI suggestions for unmapped statuses – reasonable results
  3. Manual: request AI suggestions for environment patterns – reasonable results
  4. Manual: bulk-apply suggestions – mappings created correctly
  1. All Phase 1 checks pass
  2. Navigation: “Mappings” section appears in settings sidebar with 4 sub-pages
  3. Deep links: /settings/github?tab=label-mappings redirects to /settings/mappings/labels?provider=github
  4. Scope grouping: mappings are visually grouped into Tenant Defaults / Provider Overrides
  5. Create tenant default: adding a mapping with “All Providers” scope creates a provider=NULL row
  6. Create provider override: adding a mapping with a specific provider only applies to that provider
  7. Inheritance: filtering to a single provider shows both tenant defaults (dimmed) and provider overrides
  8. Unmapped panel: labels/statuses from synced issues that lack mappings are shown with quick-add actions
  9. Migration wizard: duplicate mappings across providers are detected and consolidation is offered
  10. Old tabs: label/status/environment tabs removed from GitHub/Jira/Linear settings pages
  11. Old URLs: /settings/link-mappings redirects to /settings/mappings/relationships
  1. All previous checks pass
  2. Manual: apply a template – creates expected mappings
  3. Manual: preview reprocessing – shows accurate diff
  4. Manual: export and re-import mappings – round-trip successful