Per-Project Integration Architecture
Problem
Organizations have multiple Jira projects with different configurations:
- Project A uses Zephyr Scale with standard fields
- Project B uses Xray with custom fields
- Project C uses plain Jira with "Task" issue type and labels for test tracking
TestIntel currently has a single global config for each integration. This limits users to one Jira project and one integration type at a time.
Design
Extended Project Model
Each TestIntel project carries its own integration configuration:
class ProjectIntegration(BaseModel):
type: str = "" # "zephyr" | "xray" | "jira" | ""
jira_project_key: str = "" # e.g. "HR", "SHOP"
jira_board_id: str = "" # optional: specific Jira board
field_mapping: dict = {} # Jira field ID -> TestIntel field
status_mapping: dict = {} # Jira status -> TestIntel test_status
discovered_schema: dict = {} # Raw schema from discover
class Project(BaseModel):
name: str
org: str = ""
repos: list[Repository] = []
workspace_path: str = "" # local folder path
jira_project_key: str = "" # legacy — still works as fallback
kb_scope: str = ""
output_path: str = ""
test_id: TestIdConfig = TestIdConfig()
integration: ProjectIntegration = ProjectIntegration()
Config Resolution (backward compatible)
When a sync operation runs, config is resolved in this order:
- Default project's integration config — if
project.integration.typeis set
- Global config — falls back to
config.yamlzephyr/xray/jira sections
- Error — if neither is configured
This means existing setups with global config keep working. New multi-project setups configure per-project.
Field Mapping
For plain Jira (no Xray/Zephyr), users configure which fields map to TestIntel fields:
# Stored in project integration config
field_mapping:
issue_type: "Test Case" # Jira issue type to pull/push
filter_label: "" # Optional: only issues with this label
filter_jql: "" # Optional: custom JQL override
steps: "customfield_10042" # or "description"
expected_result: "customfield_10043" # or ""
test_status: "status" # standard Jira field
priority: "priority" # standard Jira field
tags: "labels" # standard Jira field
Status Mapping
Maps Jira workflow statuses to TestIntel lifecycle statuses:
status_mapping:
"To Do": "Draft"
"In Progress": "Draft"
"In Review": "Draft"
"Done": "Approved"
"Closed": "Deprecated"
Discovery (per-project)
testintel discover is extended to:
- Read the default project's
jira_project_key
- Detect which integration is available (Zephyr API responds? Xray API responds? Plain Jira?)
- Scan available issue types, custom fields, workflow statuses
- Write discovered schema to the project's
integrationconfig
- For Zephyr/Xray: same as current discover behavior
- For plain Jira: list issue types → user picks one, list fields → auto-map known patterns
Sync Endpoint Changes
Current:
# Global config passed at router init
sync_router(storage, cfg.zephyr, cfg.xray, api_key, ...)
New:
# Sync endpoints resolve config from default project
def _resolve_integration(project):
proj = pm.get(project) or pm.active()
if proj.integration.type:
return proj.integration
# Fallback to global config
return global_config
UI Changes
- Project creation: option to set integration type and Jira project key
- Settings > Projects: show integration type per project
- Sync bar: buttons reflect the default project's integration type
- Discover button: in Settings or per-project config
Implementation Status
All 6 phases implemented and tested (2026-04-23). 64 tests in tests/test_project_integration.py.
Phase 1: Project Integration Model ✅
- Extend
ProjectwithProjectIntegration - Update
ProjectManagerto persist integration config - Add integration fields to project create API/CLI
- Tests: project CRUD with integration config
- No breaking changes: empty integration = fallback to global
Phase 2: Config Resolution ✅
- Sync endpoints resolve from project first, global second
_resolve_integration()helper- Tests: verify global fallback works, project override works
- No breaking changes: existing projects have empty integration = same behavior
Phase 3: Discover Per-Project ✅
testintel discoverreads default project's jira_project_key- Writes to project integration config
- For plain Jira: detect issue types, fields, statuses
- Tests: discover writes to project config
Phase 4: Jira Pull with Mapping ✅
- New
JiraTestProviderthat uses field mapping to pull tests POST /sync/jira/pullendpoint- UI: Jira Pull button (appears when integration type = "jira")
- Tests: pull with various field mappings
Phase 5: Jira Push with Mapping ✅
- Push creates Jira issues using field mapping
POST /sync/jira/pushendpoint- UI: Jira Push button
- Tests: push with field mappings
Phase 6: Migrate Zephyr/Xray to Project Config ✅
- Sync endpoints read project_key from project integration instead of global
- Global config still works as fallback
- Tests: verify both paths work
Migration Path
Existing users:
- Global config continues to work — no action required
- To use multi-project: create projects with integration config
testintel discoverauto-detects and writes project config
- Per-project config overrides global when set
Future Enhancements
- Settings UI for field mapping — visual drag-and-drop field mapper
- Jira workflow transitions — push status changes as Jira transitions
- Auto-detect integration type — discover checks if Zephyr/Xray APIs respond
- Cross-project test sharing — reference tests from another project's inventory
- Project templates — pre-configured integration settings for common setups
Per-Request Project Resolution (2026-06-03)
Problem
The original design used a single active project stored in projects.yaml as server state. The Chat route called pm.active() to resolve project settings (test preferences, repos, KB scope, inventory). This caused:
- Multi-user conflicts — User A selects Project X, User B selects Project Y → they overwrite each other's
activefield
- UI/backend mismatch — Frontend dropdown shows one project, backend uses a different one
- Stale inventory — The inventory singleton was created at startup from whatever project was active then; never updated on project switch
Solution: Per-Request Project Resolution
The frontend now sends the selected project name explicitly in every chat request:
class ChatRequest(BaseModel):
message: str
project: str | None = None # Explicit project from UI dropdown
kb_scope: str = "" # Fallback resolution
# ... other fields
Backend resolution order:
projectfield — if provided, look up by name viapm.get(name)
kb_scopematch — match against project list bykb_scopeorname
pm.active()fallback — for backward compatibility (CLI, scripts, API callers without project field)
Per-Request Inventory
Previously, ChatService received a single Inventory instance at init (scoped to whichever project was active at startup). Now:
# In chat route — inventory created fresh per request
if project and req.include_inventory:
request_inventory = Inventory(storage, scope=project.output_path, test_id_config=project.test_id)
chat_service.respond(..., inventory_override=request_inventory)
ChatService.respond() uses inventory_override when provided, falls back to the singleton for non-UI callers.
Session Isolation on Project Switch
When a user switches projects mid-conversation, the frontend:
- Checks if there are messages in the current chat
- If yes: shows confirmation modal ("Switching projects will start a new chat session")
- If confirmed: clears
sessionId, empties message history, shows fresh welcome card
- If cancelled: reverts dropdown to previous project
This ensures one chat session = one project context. No stale KB docs, preferences, or uploads leak across projects.
Test Preferences Flow
User sets Test Format: BDD in Project settings
→ Saved to projects.yaml under project.test_preferences
→ Frontend sends project="testintel" in chat request
→ Backend resolves project, reads test_preferences
→ Context builder injects "## Team Test Preferences (AUTHORITATIVE)" section
→ If test_format == "bdd": prompt swapped to bdd_scenarios
→ AI generates Gherkin without asking about format
What "Active" Means Now
The active field in projects.yaml is now only used for:
- Initial dropdown selection — when no localStorage preference exists
- CLI/API fallback — scripts that call
/chatwithout aprojectfield - Startup inventory singleton — for background operations (coverage analysis)
It is NOT used for resolving project context in UI-initiated chat requests.