From b6aca47bf4cfa1f1e7177a9faaa476a44bd3e081 Mon Sep 17 00:00:00 2001 From: ablancorobayna Date: Wed, 22 Apr 2026 23:34:31 +0100 Subject: [PATCH 1/5] feat: project-scoped Jira credentials and check_integrations tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements feedback items #4 (Project-Scoped Jira Credentials) and #5 (Integration Health Check / Credential Safety). Project-scoped credentials (#4): - Add host and email to JiraProjectConfig for per-project overrides - Resolution chain: project config → user config → env vars - API token intentionally excluded from project config (stays in env vars or user config for security) - Backward-compatible: legacy (user-only) signature still works Integration health check (#5): - New check_integrations tool reports credential status without exposing secrets — shows configured (bool), source (project/user/env), and host value but never email addresses or API tokens - Prevents agents from reading ~/.claude/mcp.json to check config - Confluence status derived from Jira credentials (same Atlassian auth) --- src/agent/session.ts | 1 + src/agent/tools/doctor.ts | 45 +++++++ src/core/config.ts | 2 + src/skills/builtin/jira/client.ts | 81 +++++++++++-- src/skills/builtin/jira/tools.ts | 32 +++-- test/skills/jira/client-project.test.ts | 152 ++++++++++++++++++++++++ 6 files changed, 291 insertions(+), 22 deletions(-) create mode 100644 test/skills/jira/client-project.test.ts diff --git a/src/agent/session.ts b/src/agent/session.ts index e9806ff..1615ad1 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -192,6 +192,7 @@ export async function startSession(options: SessionOptions): Promise { "mcp__marvin-governance__get_dashboard_sprint_summary", "mcp__marvin-governance__run_doctor", "mcp__marvin-governance__check_project_health", + "mcp__marvin-governance__check_integrations", "mcp__marvin-governance__get_started", ...pluginTools.map((t) => `mcp__marvin-governance__${t.name}`), ...codeSkillTools.map((t) => `mcp__marvin-governance__${t.name}`), diff --git a/src/agent/tools/doctor.ts b/src/agent/tools/doctor.ts index e8d14d3..d841828 100644 --- a/src/agent/tools/doctor.ts +++ b/src/agent/tools/doctor.ts @@ -3,9 +3,11 @@ import { tool, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk" import type { DocumentStore } from "../../storage/store.js"; import type { SourceManifestManager } from "../../sources/manifest.js"; import type { MarvinProjectConfig } from "../../core/config.js"; +import { loadUserConfig } from "../../core/config.js"; import { runDoctorScan, runDoctorFix } from "../../doctor/engine.js"; import { runHealthCheck } from "../../doctor/health/engine.js"; import { buildOnboardingGuide } from "../../doctor/health/onboarding.js"; +import { resolveJiraStatus } from "../../skills/builtin/jira/client.js"; export interface DoctorToolOptions { config?: MarvinProjectConfig; @@ -160,5 +162,48 @@ export function createDoctorTools( }, { annotations: { readOnlyHint: true } }, ), + + tool( + "check_integrations", + "Check which integrations (Jira, Confluence) are configured and their credential status. Returns presence/source info without exposing secrets. Use this instead of reading config files directly.", + {}, + async () => { + const jiraSources = { + project: options?.config?.jira + ? { host: options.config.jira.host, email: options.config.jira.email } + : undefined, + user: loadUserConfig().jira, + }; + + const jira = resolveJiraStatus(jiraSources); + + const result = { + jira: { + configured: jira.host.configured && jira.email.configured && jira.apiToken.configured, + host: jira.host.value ?? null, + hostSource: jira.host.source ?? null, + emailConfigured: jira.email.configured, + emailSource: jira.email.source ?? null, + apiTokenConfigured: jira.apiToken.configured, + apiTokenSource: jira.apiToken.source ?? null, + projectKey: options?.config?.jira?.projectKey?.trim() || null, + }, + confluence: { + configured: jira.host.configured && jira.email.configured && jira.apiToken.configured, + note: "Confluence uses the same Jira/Atlassian credentials.", + }, + }; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + { annotations: { readOnlyHint: true } }, + ), ]; } diff --git a/src/core/config.ts b/src/core/config.ts index 94b28eb..bbaeda6 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -53,6 +53,8 @@ export interface LegacyJiraStatusMap { } export interface JiraProjectConfig { + host?: string; + email?: string; projectKey?: string; statusMap?: | FlatJiraStatusMap diff --git a/src/skills/builtin/jira/client.ts b/src/skills/builtin/jira/client.ts index 2c5dbfe..c1848c9 100644 --- a/src/skills/builtin/jira/client.ts +++ b/src/skills/builtin/jira/client.ts @@ -266,19 +266,82 @@ export interface ResolvedJiraConfig { host: string; } -export function createJiraClient(jiraUserConfig?: { - host?: string; - email?: string; - apiToken?: string; -}): ResolvedJiraConfig | null { - const host = (jiraUserConfig?.host?.trim() ?? process.env.JIRA_HOST?.trim()) || undefined; - const email = (jiraUserConfig?.email?.trim() ?? process.env.JIRA_EMAIL?.trim()) || undefined; +export interface JiraConfigSources { + /** Project-level config (.marvin/config.yaml jira section) */ + project?: { host?: string; email?: string }; + /** User-level config (~/.config/marvin/config.yaml jira section) */ + user?: { host?: string; email?: string; apiToken?: string }; +} + +/** + * Resolve Jira credentials from project config → user config → env vars. + * Returns null if any required credential is missing. + */ +export function createJiraClient( + userConfigOrSources?: JiraConfigSources["user"] | JiraConfigSources, +): ResolvedJiraConfig | null { + // Support both legacy (user-only) and new (project+user) signatures + const sources = isConfigSources(userConfigOrSources) + ? userConfigOrSources + : { user: userConfigOrSources }; + + const host = + sources.project?.host?.trim() || + sources.user?.host?.trim() || + process.env.JIRA_HOST?.trim() || + undefined; + const email = + sources.project?.email?.trim() || + sources.user?.email?.trim() || + process.env.JIRA_EMAIL?.trim() || + undefined; const apiToken = - (jiraUserConfig?.apiToken?.trim() ?? process.env.JIRA_API_TOKEN?.trim()) || undefined; + sources.user?.apiToken?.trim() || process.env.JIRA_API_TOKEN?.trim() || undefined; if (!host || !email || !apiToken) return null; - // Normalize host for consistent jiraUrl generation const normalizedHost = host.replace(/^https?:\/\//, "").replace(/\/+$/, ""); return { client: new JiraClient({ host, email, apiToken }), host: normalizedHost }; } + +/** Check which Jira credentials are present without exposing values. */ +export function resolveJiraStatus(sources?: JiraConfigSources): { + host: { configured: boolean; value?: string; source?: string }; + email: { configured: boolean; source?: string }; + apiToken: { configured: boolean; source?: string }; +} { + const projectHost = sources?.project?.host?.trim(); + const userHost = sources?.user?.host?.trim(); + const envHost = process.env.JIRA_HOST?.trim(); + const host = projectHost || userHost || envHost; + + const projectEmail = sources?.project?.email?.trim(); + const userEmail = sources?.user?.email?.trim(); + const envEmail = process.env.JIRA_EMAIL?.trim(); + + const userToken = sources?.user?.apiToken?.trim(); + const envToken = process.env.JIRA_API_TOKEN?.trim(); + + return { + host: { + configured: !!host, + value: host ? host.replace(/^https?:\/\//, "").replace(/\/+$/, "") : undefined, + source: projectHost ? "project" : userHost ? "user" : envHost ? "env" : undefined, + }, + email: { + configured: !!(projectEmail || userEmail || envEmail), + source: projectEmail ? "project" : userEmail ? "user" : envEmail ? "env" : undefined, + }, + apiToken: { + configured: !!(userToken || envToken), + source: userToken ? "user" : envToken ? "env" : undefined, + }, + }; +} + +function isConfigSources( + value: JiraConfigSources["user"] | JiraConfigSources | undefined, +): value is JiraConfigSources { + if (!value) return false; + return "project" in value || "user" in value; +} diff --git a/src/skills/builtin/jira/tools.ts b/src/skills/builtin/jira/tools.ts index b25af37..f73f425 100644 --- a/src/skills/builtin/jira/tools.ts +++ b/src/skills/builtin/jira/tools.ts @@ -65,6 +65,12 @@ export function createJiraTools( projectConfig?: MarvinProjectConfig, ): SdkMcpToolDefinition[] { const jiraUserConfig = loadUserConfig().jira; + const jiraSources = { + project: projectConfig?.jira + ? { host: projectConfig.jira.host, email: projectConfig.jira.email } + : undefined, + user: jiraUserConfig, + }; const defaultProjectKey = projectConfig?.jira?.projectKey; const statusMap = normalizeStatusMap(projectConfig?.jira?.statusMap); @@ -142,7 +148,7 @@ export function createJiraTools( maxResults: z.number().optional().describe("Max issues to return (default 20)"), }, async (args) => { - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const result = await jira.client.searchIssuesV3( @@ -207,7 +213,7 @@ export function createJiraTools( key: z.string().describe("Jira issue key (e.g. 'PROJ-123')"), }, async (args) => { - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const issue = await jira.client.getIssue(args.key); @@ -251,7 +257,7 @@ export function createJiraTools( maxResults: z.number().optional().describe("Max issues to fetch (default 50)"), }, async (args) => { - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const result = await jira.client.searchIssues(args.jql, args.maxResults); @@ -321,7 +327,7 @@ export function createJiraTools( }; } - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const artifact = store.get(args.artifactId); @@ -375,7 +381,7 @@ export function createJiraTools( id: z.string().describe("Local JI-xxx ID"), }, async (args) => { - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const doc = store.get(args.id); @@ -484,7 +490,7 @@ export function createJiraTools( jiraKey: z.string().describe("Jira issue key (e.g. 'PROJ-123')"), }, async (args) => { - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const artifact = store.get(args.artifactId); @@ -527,7 +533,7 @@ export function createJiraTools( confluenceUrl: z.string().describe("Confluence page URL"), }, async (args) => { - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const artifact = store.get(args.artifactId); @@ -584,7 +590,7 @@ export function createJiraTools( pageId: z.string().optional().describe("Confluence page ID (alternative to URL)"), }, async (args) => { - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const resolvedId = @@ -662,7 +668,7 @@ export function createJiraTools( ), }, async (args) => { - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const fetchResult = await fetchJiraStatus( @@ -755,7 +761,7 @@ export function createJiraTools( }; } - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); // Use v3 search/jql to get statuses @@ -912,7 +918,7 @@ export function createJiraTools( }; } - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const today = new Date().toISOString().slice(0, 10); @@ -962,7 +968,7 @@ export function createJiraTools( ), }, async (args) => { - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const report = await assessSprintProgress(store, jira.client, jira.host, { @@ -1010,7 +1016,7 @@ export function createJiraTools( ), }, async (args) => { - const jira = createJiraClient(jiraUserConfig); + const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); const report = await assessArtifact(store, jira.client, jira.host, { diff --git a/test/skills/jira/client-project.test.ts b/test/skills/jira/client-project.test.ts new file mode 100644 index 0000000..43481db --- /dev/null +++ b/test/skills/jira/client-project.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { createJiraClient, resolveJiraStatus } from "../../../src/skills/builtin/jira/client.js"; + +describe("createJiraClient with project config", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("should prefer project config over user config and env vars", () => { + process.env.JIRA_HOST = "env.atlassian.net"; + process.env.JIRA_EMAIL = "env@example.com"; + process.env.JIRA_API_TOKEN = "env-token"; + + const result = createJiraClient({ + project: { host: "project.atlassian.net", email: "project@example.com" }, + user: { host: "user.atlassian.net", email: "user@example.com", apiToken: "user-token" }, + }); + + expect(result).not.toBeNull(); + expect(result!.host).toBe("project.atlassian.net"); + }); + + it("should fall back to user config when project config is partial", () => { + delete process.env.JIRA_HOST; + delete process.env.JIRA_EMAIL; + delete process.env.JIRA_API_TOKEN; + + const result = createJiraClient({ + project: { host: "project.atlassian.net" }, + user: { email: "user@example.com", apiToken: "user-token" }, + }); + + expect(result).not.toBeNull(); + expect(result!.host).toBe("project.atlassian.net"); + }); + + it("should fall back to env vars when both configs are missing", () => { + process.env.JIRA_HOST = "env.atlassian.net"; + process.env.JIRA_EMAIL = "env@example.com"; + process.env.JIRA_API_TOKEN = "env-token"; + + const result = createJiraClient({ project: undefined, user: undefined }); + + expect(result).not.toBeNull(); + expect(result!.host).toBe("env.atlassian.net"); + }); + + it("should return null when apiToken is only in project config (not supported)", () => { + delete process.env.JIRA_HOST; + delete process.env.JIRA_EMAIL; + delete process.env.JIRA_API_TOKEN; + + // Project config doesn't carry apiToken — intentionally + const result = createJiraClient({ + project: { host: "project.atlassian.net", email: "project@example.com" }, + user: undefined, + }); + + expect(result).toBeNull(); + }); + + it("should still work with legacy (user-only) signature", () => { + delete process.env.JIRA_HOST; + delete process.env.JIRA_EMAIL; + delete process.env.JIRA_API_TOKEN; + + const result = createJiraClient({ + host: "legacy.atlassian.net", + email: "legacy@example.com", + apiToken: "legacy-token", + }); + + expect(result).not.toBeNull(); + expect(result!.host).toBe("legacy.atlassian.net"); + }); +}); + +describe("resolveJiraStatus", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("should report all configured when credentials exist", () => { + process.env.JIRA_HOST = "test.atlassian.net"; + process.env.JIRA_EMAIL = "user@example.com"; + process.env.JIRA_API_TOKEN = "secret-token"; + + const status = resolveJiraStatus(); + + expect(status.host.configured).toBe(true); + expect(status.host.value).toBe("test.atlassian.net"); + expect(status.host.source).toBe("env"); + expect(status.email.configured).toBe(true); + expect(status.email.source).toBe("env"); + expect(status.apiToken.configured).toBe(true); + expect(status.apiToken.source).toBe("env"); + }); + + it("should not expose email or token values", () => { + process.env.JIRA_HOST = "test.atlassian.net"; + process.env.JIRA_EMAIL = "user@example.com"; + process.env.JIRA_API_TOKEN = "secret-token"; + + const status = resolveJiraStatus(); + const json = JSON.stringify(status); + + expect(json).not.toContain("user@example.com"); + expect(json).not.toContain("secret-token"); + }); + + it("should report unconfigured when credentials are missing", () => { + delete process.env.JIRA_HOST; + delete process.env.JIRA_EMAIL; + delete process.env.JIRA_API_TOKEN; + + const status = resolveJiraStatus(); + + expect(status.host.configured).toBe(false); + expect(status.host.value).toBeUndefined(); + expect(status.email.configured).toBe(false); + expect(status.apiToken.configured).toBe(false); + }); + + it("should report project as source when project config provides host", () => { + delete process.env.JIRA_HOST; + delete process.env.JIRA_EMAIL; + delete process.env.JIRA_API_TOKEN; + + const status = resolveJiraStatus({ + project: { host: "project.atlassian.net", email: "proj@example.com" }, + user: { apiToken: "token" }, + }); + + expect(status.host.configured).toBe(true); + expect(status.host.source).toBe("project"); + expect(status.host.value).toBe("project.atlassian.net"); + expect(status.email.source).toBe("project"); + expect(status.apiToken.source).toBe("user"); + }); + + it("should normalize host by stripping protocol", () => { + const status = resolveJiraStatus({ + project: { host: "https://myco.atlassian.net/" }, + }); + + expect(status.host.value).toBe("myco.atlassian.net"); + }); +}); From 3e9dd42c7b37b35a2fae214664afb6ab24eecb2c Mon Sep 17 00:00:00 2001 From: ablancorobayna Date: Thu, 23 Apr 2026 08:28:05 +0100 Subject: [PATCH 2/5] docs: update guides for health checks, onboarding, Sprint 0, and Jira credentials - personas.md: PO now includes use-case doc type; DM has Sprint 0 section - mcp-server.md: document diagnostic tools (run_doctor, check_project_health, get_started, check_integrations) - configuration.md: add jira.host/email project overrides, aem.currentPhase, credential resolution chain, Jira env vars - jira.md: project-scoped credentials, check_integrations usage example - getting-started.md: mention get_started and check_project_health tools --- docs/guides/getting-started.md | 19 +++++++++++++++++++ docs/guides/jira.md | 33 +++++++++++++++++++++++++++++++++ docs/guides/mcp-server.md | 9 +++++++++ docs/guides/personas.md | 4 +++- docs/reference/configuration.md | 26 ++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 1 deletion(-) diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 6bb71d8..9824eb2 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -120,6 +120,25 @@ marvin serve See the [MCP Server guide](mcp-server.md) for configuration details. +## Onboarding assistance + +If you're unsure what to do next, Marvin can guide you. Use the `get_started` tool in an MCP session (Claude Desktop or Claude Code) to get a tailored checklist based on your project's current state: + +``` +> get_started +{ + "status": "empty", + "steps": [ + { "title": "Ingest source documents", "done": false }, + { "title": "Define features", "done": false }, + ... + ], + "summary": "0 of 7 steps complete. Next: Ingest source documents." +} +``` + +You can also run `check_project_health` at any time to get recommendations about missing governance setup (sprints, Jira integration, phase readiness). + ## Next steps - **[Personas](personas.md)** — learn what each persona does and when to use it diff --git a/docs/guides/jira.md b/docs/guides/jira.md index cd46257..8e0141f 100644 --- a/docs/guides/jira.md +++ b/docs/guides/jira.md @@ -26,6 +26,39 @@ jira: Done: done ``` +Alternatively, set credentials via environment variables: `JIRA_HOST`, `JIRA_EMAIL`, `JIRA_API_TOKEN`. + +### Project-scoped credentials + +If you work with multiple Jira instances, you can override the host and email per project in `.marvin/config.yaml`: + +```yaml +jira: + host: project-specific.atlassian.net + email: project-team@example.com + projectKey: PROJ +``` + +The resolution order is: **project config → user config → environment variables**. The API token is never stored in project config — use the user config or environment variables. + +### Checking configuration + +Use the `check_integrations` MCP tool to verify your Jira setup without exposing secrets: + +``` +> check_integrations +{ + "jira": { + "configured": true, + "host": "your-instance.atlassian.net", + "hostSource": "user", + "emailConfigured": true, + "apiTokenConfigured": true, + "projectKey": "PROJ" + } +} +``` + 3. Assign the Jira skill to the relevant personas: ```bash diff --git a/docs/guides/mcp-server.md b/docs/guides/mcp-server.md index 9767590..73e1de4 100644 --- a/docs/guides/mcp-server.md +++ b/docs/guides/mcp-server.md @@ -34,4 +34,13 @@ Add Marvin to your Claude Desktop MCP config: The MCP server exposes all governance tools: creating and managing decisions, actions, questions, features, epics, sprints, tasks, and more. It also provides persona management tools (`set_persona`, `get_persona_guidance`) that let Claude switch between personas within a session. +### Diagnostic tools + +| Tool | Description | +|------|-------------| +| `run_doctor` | Scan documents for structural issues (orphaned refs, status alignment) with optional auto-repair. | +| `check_project_health` | Project-level governance health checks — flags missing sprints, unprocessed sources, Jira config, and AEM phase readiness. | +| `get_started` | Tailored onboarding guide with a step-by-step checklist that adapts to methodology and tracks completion. | +| `check_integrations` | Check integration status (Jira, Confluence) without exposing credentials. Reports which credentials are configured and their source (project/user/env). | + See the [CLI Reference](../reference/cli.md) for the full list of operations available through MCP tools. diff --git a/docs/guides/personas.md b/docs/guides/personas.md index 1d20d36..f797b4b 100644 --- a/docs/guides/personas.md +++ b/docs/guides/personas.md @@ -16,7 +16,7 @@ The Product Owner focuses on product vision, stakeholder needs, backlog prioriti - Make decisions about scope, priority, and trade-offs - Accept or reject work results based on acceptance criteria -**Document types:** decisions, questions, actions, features +**Document types:** decisions, questions, actions, features, use-cases **Contribution types:** stakeholder-feedback, acceptance-result, priority-change, market-insight @@ -44,6 +44,8 @@ The Delivery Manager focuses on project delivery, risk management, team coordina **Contribution types:** risk-finding, blocker-report, dependency-update, status-assessment +**Sprint 0 support:** The DM persona understands Sprint 0 as a variable-duration bootstrapping phase (not a regular time-boxed sprint). When a project has work items but no sprints, the DM will proactively suggest creating Sprint 0 to cover infrastructure provisioning, backlog refinement, ceremony scheduling, and integration setup. + **When to use:** When you need to manage how things get built — planning sprints, tracking progress, running meetings, managing risks, or generating status reports. ```bash diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 998dd88..3c007bf 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -79,6 +79,8 @@ git: # Jira project settings (optional) jira: + host: project-specific.atlassian.net # overrides user config and env var + email: project-team@example.com # overrides user config and env var projectKey: PROJ statusMap: To Do: open @@ -87,6 +89,10 @@ jira: Blocked: default: blocked inSprint: in-progress + +# AEM phase tracking (sap-aem methodology only) +aem: + currentPhase: assess-use-case ``` ### Fields @@ -99,8 +105,11 @@ jira: | `personas..extraInstructions` | string | no | Additional system prompt text for the persona. | | `skills.` | string[] | no | List of skill IDs assigned to a persona. | | `git.remote` | string | no | Remote URL for governance data sync. | +| `jira.host` | string | no | Project-specific Jira host. Overrides user config and `JIRA_HOST` env var. | +| `jira.email` | string | no | Project-specific Jira email. Overrides user config and `JIRA_EMAIL` env var. | | `jira.projectKey` | string | no | Jira project key for integration. | | `jira.statusMap` | object | no | Mapping of Jira statuses to Marvin statuses. See below. | +| `aem.currentPhase` | string | no | Current AEM phase (`assess-use-case`, `assess-technology`, `define-solution`). Managed by the `advance_phase` tool. | ### Jira status mapping @@ -133,8 +142,25 @@ Methodologies are plugins that define additional document types, tools, and pers **sap-aem** — Extends generic-agile with SAP-specific artifacts: use cases (UC), tech assessments (TA), and extension designs (XD). Includes phase management and SAP BTP guidance. +## Jira credential resolution + +Jira credentials are resolved in priority order: **project config → user config → environment variables**. This allows per-project overrides for teams working across multiple Jira instances. + +| Credential | Project config | User config | Environment variable | +|------------|---------------|-------------|---------------------| +| Host | `jira.host` | `jira.host` | `JIRA_HOST` | +| Email | `jira.email` | `jira.email` | `JIRA_EMAIL` | +| API Token | — (not supported) | `jira.apiToken` | `JIRA_API_TOKEN` | + +The API token is intentionally excluded from project config to avoid committing secrets. Use the user config or environment variables for the token. + +Use the `check_integrations` MCP tool to verify which credentials are configured and their source — it reports status without exposing secret values. + ## Environment variables | Variable | Description | |----------|-------------| | `ANTHROPIC_API_KEY` | Anthropic API key (overrides user config). | +| `JIRA_HOST` | Jira Cloud instance hostname (fallback when not in user/project config). | +| `JIRA_EMAIL` | Jira account email (fallback when not in user/project config). | +| `JIRA_API_TOKEN` | Jira API token (fallback when not in user config). | From c3de3f6e193811e6a79d2b5c22faadede7c5542e Mon Sep 17 00:00:00 2001 From: ablancorobayna Date: Thu, 23 Apr 2026 08:48:34 +0100 Subject: [PATCH 3/5] fix: address PR review findings for Jira credentials and integrations - fetch_jira_statuses: replace hand-rolled Basic auth with jira.client.searchIssuesV3 so project-scoped overrides are honored - check_integrations: wrap in try/catch for ConfigError safety - createJiraClient: pass normalizedHost to JiraClient constructor to avoid double normalization - isConfigSources: reject objects with top-level host/email/apiToken to prevent legacy objects being misidentified as sources - resolveJiraStatus: tighten source field to "project"|"user"|"env" literal union - Test: restore process.env via Reflect.deleteProperty instead of reassigning the proxy object - Docs: add missing emailSource/apiTokenSource to jira.md example --- docs/guides/jira.md | 2 + src/agent/tools/doctor.ts | 78 ++++++++++++++----------- src/skills/builtin/jira/client.ts | 15 +++-- src/skills/builtin/jira/tools.ts | 33 +++-------- test/skills/jira/client-project.test.ts | 21 ++++++- 5 files changed, 86 insertions(+), 63 deletions(-) diff --git a/docs/guides/jira.md b/docs/guides/jira.md index 8e0141f..ba114aa 100644 --- a/docs/guides/jira.md +++ b/docs/guides/jira.md @@ -53,7 +53,9 @@ Use the `check_integrations` MCP tool to verify your Jira setup without exposing "host": "your-instance.atlassian.net", "hostSource": "user", "emailConfigured": true, + "emailSource": "user", "apiTokenConfigured": true, + "apiTokenSource": "env", "projectKey": "PROJ" } } diff --git a/src/agent/tools/doctor.ts b/src/agent/tools/doctor.ts index d841828..64135d4 100644 --- a/src/agent/tools/doctor.ts +++ b/src/agent/tools/doctor.ts @@ -168,40 +168,52 @@ export function createDoctorTools( "Check which integrations (Jira, Confluence) are configured and their credential status. Returns presence/source info without exposing secrets. Use this instead of reading config files directly.", {}, async () => { - const jiraSources = { - project: options?.config?.jira - ? { host: options.config.jira.host, email: options.config.jira.email } - : undefined, - user: loadUserConfig().jira, - }; - - const jira = resolveJiraStatus(jiraSources); - - const result = { - jira: { - configured: jira.host.configured && jira.email.configured && jira.apiToken.configured, - host: jira.host.value ?? null, - hostSource: jira.host.source ?? null, - emailConfigured: jira.email.configured, - emailSource: jira.email.source ?? null, - apiTokenConfigured: jira.apiToken.configured, - apiTokenSource: jira.apiToken.source ?? null, - projectKey: options?.config?.jira?.projectKey?.trim() || null, - }, - confluence: { - configured: jira.host.configured && jira.email.configured && jira.apiToken.configured, - note: "Confluence uses the same Jira/Atlassian credentials.", - }, - }; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(result, null, 2), + try { + const jiraSources = { + project: options?.config?.jira + ? { host: options.config.jira.host, email: options.config.jira.email } + : undefined, + user: loadUserConfig().jira, + }; + + const jira = resolveJiraStatus(jiraSources); + + const result = { + jira: { + configured: jira.host.configured && jira.email.configured && jira.apiToken.configured, + host: jira.host.value ?? null, + hostSource: jira.host.source ?? null, + emailConfigured: jira.email.configured, + emailSource: jira.email.source ?? null, + apiTokenConfigured: jira.apiToken.configured, + apiTokenSource: jira.apiToken.source ?? null, + projectKey: options?.config?.jira?.projectKey?.trim() || null, }, - ], - }; + confluence: { + configured: jira.host.configured && jira.email.configured && jira.apiToken.configured, + note: "Confluence uses the same Jira/Atlassian credentials.", + }, + }; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `Integration check error: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } }, { annotations: { readOnlyHint: true } }, ), diff --git a/src/skills/builtin/jira/client.ts b/src/skills/builtin/jira/client.ts index c1848c9..fd9aaaa 100644 --- a/src/skills/builtin/jira/client.ts +++ b/src/skills/builtin/jira/client.ts @@ -301,14 +301,17 @@ export function createJiraClient( if (!host || !email || !apiToken) return null; const normalizedHost = host.replace(/^https?:\/\//, "").replace(/\/+$/, ""); - return { client: new JiraClient({ host, email, apiToken }), host: normalizedHost }; + return { + client: new JiraClient({ host: normalizedHost, email, apiToken }), + host: normalizedHost, + }; } /** Check which Jira credentials are present without exposing values. */ export function resolveJiraStatus(sources?: JiraConfigSources): { - host: { configured: boolean; value?: string; source?: string }; - email: { configured: boolean; source?: string }; - apiToken: { configured: boolean; source?: string }; + host: { configured: boolean; value?: string; source?: CredentialSource }; + email: { configured: boolean; source?: CredentialSource }; + apiToken: { configured: boolean; source?: CredentialSource }; } { const projectHost = sources?.project?.host?.trim(); const userHost = sources?.user?.host?.trim(); @@ -339,9 +342,13 @@ export function resolveJiraStatus(sources?: JiraConfigSources): { }; } +type CredentialSource = "project" | "user" | "env"; + function isConfigSources( value: JiraConfigSources["user"] | JiraConfigSources | undefined, ): value is JiraConfigSources { if (!value) return false; + // Reject legacy objects that have top-level credentials (host/email/apiToken) + if ("host" in value || "email" in value || "apiToken" in value) return false; return "project" in value || "user" in value; } diff --git a/src/skills/builtin/jira/tools.ts b/src/skills/builtin/jira/tools.ts index f73f425..89842ad 100644 --- a/src/skills/builtin/jira/tools.ts +++ b/src/skills/builtin/jira/tools.ts @@ -764,40 +764,25 @@ export function createJiraTools( const jira = createJiraClient(jiraSources); if (!jira) return jiraNotConfiguredError(); - // Use v3 search/jql to get statuses - const host = jira.host; - const auth = `Basic ${Buffer.from( - `${(jiraUserConfig?.email ?? process.env.JIRA_EMAIL)!}:${(jiraUserConfig?.apiToken ?? process.env.JIRA_API_TOKEN)!}`, - ).toString("base64")}`; - - const params = new URLSearchParams({ - jql: `project = ${resolvedProjectKey}`, - maxResults: String(args.maxResults ?? 100), - fields: "status", - }); - - const resp = await fetch(`https://${host}/rest/api/3/search/jql?${params}`, { - headers: { Authorization: auth, Accept: "application/json" }, - }); - - if (!resp.ok) { - const text = await resp.text().catch(() => ""); + let data: { total: number; issues: { fields: { status: { name: string } } }[] }; + try { + data = await jira.client.searchIssuesV3( + `project = ${resolvedProjectKey}`, + ["status"], + args.maxResults ?? 100, + ); + } catch (err) { return { content: [ { type: "text" as const, - text: `Jira API error ${resp.status}: ${text}`, + text: `Jira API error: ${err instanceof Error ? err.message : String(err)}`, }, ], isError: true, }; } - const data = (await resp.json()) as { - total: number; - issues: { fields: { status: { name: string } } }[]; - }; - // Collect distinct statuses const statusCounts = new Map(); for (const issue of data.issues) { diff --git a/test/skills/jira/client-project.test.ts b/test/skills/jira/client-project.test.ts index 43481db..0a5491a 100644 --- a/test/skills/jira/client-project.test.ts +++ b/test/skills/jira/client-project.test.ts @@ -1,11 +1,28 @@ import { describe, it, expect, afterEach } from "vitest"; import { createJiraClient, resolveJiraStatus } from "../../../src/skills/builtin/jira/client.js"; +function restoreEnv(original: Record): void { + // Remove keys that weren't in the original + for (const key of Object.keys(process.env)) { + if (!(key in original)) { + Reflect.deleteProperty(process.env, key); + } + } + // Restore original values + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } +} + describe("createJiraClient with project config", () => { const originalEnv = { ...process.env }; afterEach(() => { - process.env = { ...originalEnv }; + restoreEnv(originalEnv); }); it("should prefer project config over user config and env vars", () => { @@ -81,7 +98,7 @@ describe("resolveJiraStatus", () => { const originalEnv = { ...process.env }; afterEach(() => { - process.env = { ...originalEnv }; + restoreEnv(originalEnv); }); it("should report all configured when credentials exist", () => { From dc548e9c7744bb4fed873e9188eb7144f73f2a6c Mon Sep 17 00:00:00 2001 From: ablancorobayna Date: Fri, 24 Apr 2026 08:36:59 +0100 Subject: [PATCH 4/5] chore: bump version to 0.6.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a8ec770..09113c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mrvn-cli", - "version": "0.6.1", + "version": "0.6.2", "description": "AI-powered software product development assistant with Product Owner, Delivery Manager, and Technical Lead personas", "type": "module", "bin": { From fa993ae5c5b50e1211a489e8a4b25715af1592ad Mon Sep 17 00:00:00 2001 From: ablancorobayna Date: Fri, 24 Apr 2026 09:02:20 +0100 Subject: [PATCH 5/5] fix: add json fence to docs example and export JiraIntegrationStatus type - Add json language specifier to check_integrations code block (MD040) - Extract and export CredentialSource and JiraIntegrationStatus from resolveJiraStatus inline return type for downstream reuse --- docs/guides/jira.md | 4 ++-- src/skills/builtin/jira/client.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/guides/jira.md b/docs/guides/jira.md index ba114aa..e8dd583 100644 --- a/docs/guides/jira.md +++ b/docs/guides/jira.md @@ -45,8 +45,8 @@ The resolution order is: **project config → user config → environment variab Use the `check_integrations` MCP tool to verify your Jira setup without exposing secrets: -``` -> check_integrations +```json +// > check_integrations { "jira": { "configured": true, diff --git a/src/skills/builtin/jira/client.ts b/src/skills/builtin/jira/client.ts index fd9aaaa..6605f23 100644 --- a/src/skills/builtin/jira/client.ts +++ b/src/skills/builtin/jira/client.ts @@ -307,12 +307,16 @@ export function createJiraClient( }; } -/** Check which Jira credentials are present without exposing values. */ -export function resolveJiraStatus(sources?: JiraConfigSources): { +export type CredentialSource = "project" | "user" | "env"; + +export interface JiraIntegrationStatus { host: { configured: boolean; value?: string; source?: CredentialSource }; email: { configured: boolean; source?: CredentialSource }; apiToken: { configured: boolean; source?: CredentialSource }; -} { +} + +/** Check which Jira credentials are present without exposing values. */ +export function resolveJiraStatus(sources?: JiraConfigSources): JiraIntegrationStatus { const projectHost = sources?.project?.host?.trim(); const userHost = sources?.user?.host?.trim(); const envHost = process.env.JIRA_HOST?.trim(); @@ -342,8 +346,6 @@ export function resolveJiraStatus(sources?: JiraConfigSources): { }; } -type CredentialSource = "project" | "user" | "env"; - function isConfigSources( value: JiraConfigSources["user"] | JiraConfigSources | undefined, ): value is JiraConfigSources {