diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 00b9450d..801cce4d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -48,7 +48,7 @@ { "name": "bitwarden-atlassian-tools", "source": "./plugins/bitwarden-atlassian-tools", - "version": "2.8.2", + "version": "2.9.0", "description": "Atlassian access via MCP server with deep Jira issue research skill, JQL search, Confluence pages, CQL search, attachments, and opt-in Jira write tools" }, { diff --git a/README.md b/README.md index 287d604c..e67fa23b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A curated collection of plugins for AI-assisted development at Bitwarden. Enable | [bitwarden-tech-lead](plugins/bitwarden-tech-lead/) | 3.1.0 | Tech lead for technical planning, architecture coherence, and surfacing patterns to Technical Strategy Ideas | | [bitwarden-shepherd](plugins/bitwarden-shepherd/) | 1.0.1 | Champion of a technical strategy — shepherds a TSI through evaluation into the funnel, then through to adoption | | [bitwarden-adr-tools](plugins/bitwarden-adr-tools/) | 1.0.0 | Architecture Decision Record tools: checking designs and plans against the ADR catalog, locating and summarizing ADRs | -| [bitwarden-atlassian-tools](plugins/bitwarden-atlassian-tools/) | 2.8.2 | Atlassian access via MCP server with deep Jira issue research skill and opt-in Jira write tools | +| [bitwarden-atlassian-tools](plugins/bitwarden-atlassian-tools/) | 2.9.0 | Atlassian access via MCP server with deep Jira issue research skill and opt-in Jira write tools | | [bitwarden-code-review](plugins/bitwarden-code-review/) | 2.7.0 | Autonomous code review agent following Bitwarden engineering standards with GitHub integration | | [bitwarden-delivery-tools](plugins/bitwarden-delivery-tools/) | 3.5.2 | Delivery lifecycle skills: initiative funnel navigation, work transitions, architectural judgment, feature flags, commits, PRs, preflight, labeling, Jira ticket filing | | [bitwarden-designer](plugins/bitwarden-designer/) | 0.1.0 | Product designer persona: Code of Conduct and 30/60/90 critique, critique facilitation; dispatches into bitwarden-design-tools | diff --git a/plugins/bitwarden-atlassian-tools/.claude-plugin/plugin.json b/plugins/bitwarden-atlassian-tools/.claude-plugin/plugin.json index 106abf8e..4e058e49 100644 --- a/plugins/bitwarden-atlassian-tools/.claude-plugin/plugin.json +++ b/plugins/bitwarden-atlassian-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bitwarden-atlassian-tools", - "version": "2.8.2", + "version": "2.9.0", "description": "Atlassian access via MCP server with deep Jira issue research skill, JQL search, Confluence pages, CQL search, attachments, and opt-in Jira write tools", "author": { "name": "Bitwarden" diff --git a/plugins/bitwarden-atlassian-tools/CHANGELOG.md b/plugins/bitwarden-atlassian-tools/CHANGELOG.md index f99785ff..96a482db 100644 --- a/plugins/bitwarden-atlassian-tools/CHANGELOG.md +++ b/plugins/bitwarden-atlassian-tools/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the Bitwarden Atlassian Tools plugin will be documented i The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.9.0] - 2026-10-30 + +### Added + +- `transition_issue` moves an issue to a status named by the caller, such as `In Progress` or `Done`, and finds the workflow transition that reaches it, so no transition ids or workflow knowledge are needed up front. An issue already in the target status is left alone, which keeps the tool safe to call repeatedly from automation. When no transition reaches the target, it lists the statuses that are reachable instead. Like the other write tools it defaults to a dry run, needs `ATLASSIAN_JIRA_WRITE_TOKEN` only for the live transition, and reads the status back afterwards to confirm where the issue landed. + ## [2.8.2] - 2026-10-30 ### Fixed diff --git a/plugins/bitwarden-atlassian-tools/README.md b/plugins/bitwarden-atlassian-tools/README.md index 1a9a14bc..7d366c60 100644 --- a/plugins/bitwarden-atlassian-tools/README.md +++ b/plugins/bitwarden-atlassian-tools/README.md @@ -4,7 +4,7 @@ Atlassian access via a custom MCP server providing Jira issue retrieval, JQL search, Confluence page reading, CQL search, and attachment downloads. -Read access is the default and always available. Jira write access (creating work items, links, and comments) is **opt-in per install**: `create_issue`, `link_issues`, and `add_issue_comment` are always listed and their dry-run preview always works, but without `ATLASSIAN_JIRA_WRITE_TOKEN` a live write refuses to execute. Confluence remains read-only with no write path. +Read access is the default and always available. Jira write access (creating work items, links, and comments, and transitioning issues) is **opt-in per install**: `create_issue`, `link_issues`, `add_issue_comment`, and `transition_issue` are always listed and their dry-run preview always works, but without `ATLASSIAN_JIRA_WRITE_TOKEN` a live write refuses to execute. Confluence remains read-only with no write path. ## Installation @@ -17,7 +17,7 @@ export ATLASSIAN_EMAIL="your-email@company.com" export ATLASSIAN_JIRA_READ_ONLY_TOKEN="your-jira-scoped-token" export ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN="your-confluence-scoped-token" -# Optional — enables the Jira write tools (create_issue, link_issues, add_issue_comment). +# Optional — enables the Jira write tools (create_issue, link_issues, add_issue_comment, transition_issue). # Omit to keep this install read-only. export ATLASSIAN_JIRA_WRITE_TOKEN="your-jira-write-scoped-token" ``` @@ -88,28 +88,29 @@ The Jira Agile (Software) endpoints behind `list_boards`, `get_sprints`, and `ge ### Jira (write, requires `ATLASSIAN_JIRA_WRITE_TOKEN`) -All three tools default to a dry run that returns the exact payload without sending it. A live write requires an explicit `dryRun: false`. Dry runs need no write token. +All four tools default to a dry run that returns the exact payload without sending it. A live write requires an explicit `dryRun: false`. Dry runs need no write token. | Tool | Purpose | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `create_issue` | Create one work item in any project. Carries no project-specific field knowledge: pass anything beyond the common core through `fields`, keyed by field id from `get_create_fields` | | `link_issues` | Link two work items. For a dependency, takes `blockerKey` and `blockedKey` and maps them onto Jira's inward/outward sides internally so the direction cannot be inverted | | `add_issue_comment` | Add a comment to an existing issue. The body is markdown: emphasis, links, inline and fenced code, lists, and tables all render | +| `transition_issue` | Move an issue to a status by name. Finds the transition that reaches it, does nothing if the issue is already there, and lists the reachable statuses if none does | -The classic `write:jira-work` scope alone covers all three write tools — it grants creating and editing issues and posting comments as the user. For a **scoped (granular)** write token, add these instead, on top of the read scopes above: +The classic `write:jira-work` scope alone covers all four write tools — it grants creating and editing issues and posting comments as the user. For a **scoped (granular)** write token, add these instead, on top of the read scopes above: | Scope | Required for | | ----------------------------- | ------------------------------------------------------------------ | | `read:issue:jira` | `create_issue` | | `read:issue:jira-software` | `create_issue` | -| `write:issue:jira` | `create_issue`, `link_issues` | +| `write:issue:jira` | `create_issue`, `link_issues`, `transition_issue` | | `write:issue:jira-software` | `create_issue`, `link_issues` | | `write:issue-link:jira` | `link_issues` | | `write:comment:jira` | `create_issue`, `link_issues` (scope-only), `add_issue_comment` | | `write:comment.property:jira` | `create_issue` (required even though it never sends a comment) | | `write:attachment:jira` | `create_issue` (required even though it never sends an attachment) | -Grant the **whole** set, not a subset — a token holding only some of them fails every write with `401 Unauthorized; scope does not match`. For a scoped write token covering all three write tools, that's: +Grant the **whole** set, not a subset — a token holding only some of them fails every write with `401 Unauthorized; scope does not match`. For a scoped write token covering all four write tools, that's: ``` read:issue:jira @@ -124,7 +125,7 @@ write:issue:jira `get_create_fields` needs no additional scope. It calls the createmeta endpoints, which the existing read-only token already satisfies. -Token scope is separate from Jira project permission. Creating also requires the **Create Issues** permission in the target project, linking requires **Link Issues**, and commenting requires **Add Comments**. In a project where the user lacks Create Issues, Jira answers `You cannot create issues in this project`, which `get_create_fields` reports as an ordinary result rather than an error. +Token scope is separate from Jira project permission. Creating also requires the **Create Issues** permission in the target project, linking requires **Link Issues**, commenting requires **Add Comments**, and transitioning requires **Transition Issues**. In a project where the user lacks Create Issues, Jira answers `You cannot create issues in this project`, which `get_create_fields` reports as an ordinary result rather than an error. A leaked write token permits more than any one tool uses: `write:comment.property:jira` and `write:attachment:jira` are granted only because Atlassian rejects a narrower scope set, so the token can also set comment properties and add attachments across every project the user can reach, in addition to the comments `add_issue_comment` itself sends. Treat this token as higher-blast-radius than the read-only token and rotate it accordingly. @@ -149,6 +150,7 @@ The MCP tools are available as `mcp__bitwarden-atlassian__`. Examples - Search Confluence: `mcp__bitwarden-atlassian__search_confluence_cql` with `cql: "space = EN AND text ~ \"search term\""` - Preview a ticket before creating it: `mcp__bitwarden-atlassian__create_issue` with `project: "PM"`, `issueType: "Story"`, `summary: "Add CSV export to the item list"` — omit `dryRun` (defaults to `true`) to get the payload back without creating anything - Preview a comment before posting it: `mcp__bitwarden-atlassian__add_issue_comment` with `issueIdOrKey: "PROJ-123"`, `body: "Looks good to me, one nit: the **guard** is inverted."` — omit `dryRun` (defaults to `true`) to get the payload back without posting anything +- Preview a status change: `mcp__bitwarden-atlassian__transition_issue` with `issueIdOrKey: "PROJ-123"`, `status: "In Progress"` — omit `dryRun` (defaults to `true`) to see which transition would apply without moving the issue ## Skills diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/index.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/index.ts index b2b653a9..f52c2d7c 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/index.ts +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/index.ts @@ -55,6 +55,7 @@ import getCreateFields from "./tools/get-create-fields.js"; import createIssue from "./tools/create-issue.js"; import linkIssues from "./tools/link-issues.js"; import addIssueComment from "./tools/add-issue-comment.js"; +import transitionIssue from "./tools/transition-issue.js"; const tools: ToolDefinition[] = [ getIssue, @@ -76,6 +77,7 @@ const tools: ToolDefinition[] = [ createIssue, linkIssues, addIssueComment, + transitionIssue, ]; async function main() { diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.ts index 36dd2b04..6e7b1287 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.ts +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.ts @@ -20,6 +20,7 @@ import { JiraRemoteLink, JiraCreateMetaIssueType, JiraCreateMetaField, + JiraTransition, } from "./types.js"; export class JiraClient { @@ -403,6 +404,33 @@ export class JiraClient { }); } + /** + * List the transitions available from an issue's current status, as this + * user. Transitions the user lacks permission for, or whose conditions fail, + * are omitted by Jira. + */ + async getTransitions(issueIdOrKey: string): Promise { + const response = await this.client.get<{ transitions: JiraTransition[] }>( + `${this.API_BASE}/issue/${issueIdOrKey}/transitions`, + ); + + return response.data.transitions; + } + + /** + * Move an issue through a workflow transition. Jira answers 204 with no + * body on success. + */ + async transitionIssue( + issueIdOrKey: string, + transitionId: string, + ): Promise { + await this.client.post( + `${this.API_BASE}/issue/${issueIdOrKey}/transitions`, + { transition: { id: transitionId } }, + ); + } + // ── Create-screen Metadata (read-only, works with either token) ───── /** diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/types.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/types.ts index 444f2354..b932babb 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/types.ts +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/types.ts @@ -54,6 +54,17 @@ export interface JiraStatus { }; } +/** + * A workflow transition available from an issue's current status, as returned + * by GET /issue/{key}/transitions. `name` is the transition's own label (e.g. + * "Start Progress"); `to.name` is the status it lands on. + */ +export interface JiraTransition { + id: string; + name: string; + to: JiraStatus; +} + export interface JiraPriority { id: string; name: string; diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/transition-issue.spec.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/transition-issue.spec.ts new file mode 100644 index 00000000..08c2f3e3 --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/transition-issue.spec.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const mockPost = vi.fn(); +const mockGet = vi.fn(); + +vi.mock("axios", () => { + const mockAxios: any = { + create: vi.fn(() => ({ + post: mockPost, + get: mockGet, + interceptors: { response: { use: vi.fn() } }, + })), + }; + return { default: mockAxios }; +}); + +import transitionIssueTool from "./transition-issue.js"; +import { validateInput, TransitionIssueSchema } from "../utils/validation.js"; + +function statusResponse(name: string) { + return { data: { key: "AI-27", fields: { status: { name } } } }; +} + +const transitionsResponse = { + data: { + transitions: [ + { id: "11", name: "Start Progress", to: { name: "In Progress" } }, + { id: "31", name: "Resolve", to: { name: "Done" } }, + { id: "32", name: "Close", to: { name: "Done" } }, + ], + }, +}; + +describe("TransitionIssueSchema", () => { + it("defaults dryRun to true", () => { + const parsed = validateInput(TransitionIssueSchema, { + issueIdOrKey: "AI-27", + status: "Done", + }); + + expect(parsed.dryRun).toBe(true); + }); + + it("rejects a whitespace-only status", () => { + expect(() => + validateInput(TransitionIssueSchema, { + issueIdOrKey: "AI-27", + status: " ", + }), + ).toThrow(/Target status cannot be empty/); + }); + + it("rejects a malformed issue key", () => { + expect(() => + validateInput(TransitionIssueSchema, { + issueIdOrKey: "AI-27/../../myself", + status: "Done", + }), + ).toThrow(/valid Jira issue key/); + }); +}); + +describe("transition_issue handler", () => { + const ENV_KEYS = [ + "ATLASSIAN_CLOUD_ID", + "ATLASSIAN_EMAIL", + "ATLASSIAN_JIRA_READ_ONLY_TOKEN", + "ATLASSIAN_JIRA_WRITE_TOKEN", + ] as const; + const saved: Record = {}; + + beforeEach(() => { + vi.clearAllMocks(); + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + process.env.ATLASSIAN_CLOUD_ID = "test-cloud-id"; + process.env.ATLASSIAN_EMAIL = "user@example.com"; + process.env.ATLASSIAN_JIRA_READ_ONLY_TOKEN = "read-token"; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + }); + + it("is a no-op when the issue is already in the target status", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockGet.mockResolvedValueOnce(statusResponse("Done")); + + const out = await transitionIssueTool.handler({ + issueIdOrKey: "AI-27", + status: "done", + dryRun: false, + }); + + expect(out).toContain("AI-27 is already in Done"); + expect(mockGet).toHaveBeenCalledTimes(1); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("reports reachable statuses when no transition leads to the target", async () => { + mockGet + .mockResolvedValueOnce(statusResponse("To Do")) + .mockResolvedValueOnce(transitionsResponse); + + const out = await transitionIssueTool.handler({ + issueIdOrKey: "AI-27", + status: "In Review", + }); + + expect(out).toContain("No transition from To Do to In Review"); + expect(out).toContain("Statuses reachable from To Do: In Progress, Done."); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("previews the matching transition on a dry run without posting", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockGet + .mockResolvedValueOnce(statusResponse("To Do")) + .mockResolvedValueOnce(transitionsResponse); + + const out = await transitionIssueTool.handler({ + issueIdOrKey: "AI-27", + status: "in progress", + }); + + expect(out).toContain("Dry run: transition AI-27"); + expect(out).toContain("**To Do** -> **In Progress**"); + expect(out).toContain('"id": "11"'); + expect(out).not.toContain("ATLASSIAN_JIRA_WRITE_TOKEN is not set"); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("notes the missing write token on a dry run from a read-only install", async () => { + mockGet + .mockResolvedValueOnce(statusResponse("To Do")) + .mockResolvedValueOnce(transitionsResponse); + + const out = await transitionIssueTool.handler({ + issueIdOrKey: "AI-27", + status: "Done", + }); + + expect(out).toContain("ATLASSIAN_JIRA_WRITE_TOKEN is not set"); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("refuses a live transition when no write token is set", async () => { + mockGet + .mockResolvedValueOnce(statusResponse("To Do")) + .mockResolvedValueOnce(transitionsResponse); + + const out = await transitionIssueTool.handler({ + issueIdOrKey: "AI-27", + status: "Done", + dryRun: false, + }); + + expect(out).toContain("Refusing to transition"); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("posts the first matching transition and verifies the new status", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockGet + .mockResolvedValueOnce(statusResponse("To Do")) + .mockResolvedValueOnce(transitionsResponse) + .mockResolvedValueOnce(statusResponse("Done")); + mockPost.mockResolvedValueOnce({ status: 204 }); + + const out = await transitionIssueTool.handler({ + issueIdOrKey: "AI-27", + status: "Done", + dryRun: false, + }); + + expect(mockPost).toHaveBeenCalledWith( + "/rest/api/3/issue/AI-27/transitions", + { transition: { id: "31" } }, + ); + expect(out).toContain("Transitioned **AI-27**: To Do -> Done."); + expect(out).toContain("Verified: reading AI-27 back shows status Done."); + }); + + it("flags a status that moved on after the transition", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockGet + .mockResolvedValueOnce(statusResponse("To Do")) + .mockResolvedValueOnce(transitionsResponse) + .mockResolvedValueOnce(statusResponse("Closed")); + mockPost.mockResolvedValueOnce({ status: 204 }); + + const out = await transitionIssueTool.handler({ + issueIdOrKey: "AI-27", + status: "Done", + dryRun: false, + }); + + expect(out).toContain("Could not verify"); + expect(out).toContain("shows status Closed, not Done"); + }); + + it("adds the scope hint when the live transition fails authentication", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockGet + .mockResolvedValueOnce(statusResponse("To Do")) + .mockResolvedValueOnce(transitionsResponse); + mockPost.mockRejectedValueOnce( + new Error("JIRA authentication failed. Check your API token and email."), + ); + + const out = await transitionIssueTool.handler({ + issueIdOrKey: "AI-27", + status: "Done", + dryRun: false, + }); + + expect(out).toContain("Error transitioning issue"); + expect(out).toContain("verify it carries the full scope"); + }); +}); diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/transition-issue.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/transition-issue.ts new file mode 100644 index 00000000..bbcb748f --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/transition-issue.ts @@ -0,0 +1,177 @@ +/** + * Transition Issue Tool (write, opt-in) + * + * Moves an issue to a target status by the status's name. The caller names + * where the issue should end up, and the tool finds the transition that gets + * it there, so no workflow knowledge is needed up front. An issue already in + * the target status is a no-op rather than an error, which keeps the tool safe + * to call repeatedly from automation. + * + * The current status and available transitions are read with the read-only + * token, so a dry run is accurate on a read-only install. Only the transition + * itself uses ATLASSIAN_JIRA_WRITE_TOKEN. + */ + +import { JiraClient } from "../jira/client.js"; +import { hasJiraWriteToken } from "../jira/auth.js"; +import { + validateInput, + TransitionIssueSchema, + ToolDefinition, +} from "../utils/validation.js"; +import { + writeTokenDryRunNote, + writeTokenRefusalMessage, + isWriteAuthError, + writeScopeHint, +} from "../utils/write-guard.js"; + +function sameStatus(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +async function readStatus( + client: JiraClient, + issueIdOrKey: string, +): Promise { + const issue = await client.getIssue(issueIdOrKey, ["status"]); + return issue.fields.status.name; +} + +/** + * Read the status back after a live transition. Uses a fresh read-mode client + * so this never requires a read scope on the write token. + */ +async function verifyStatus( + issueIdOrKey: string, + want: string, +): Promise { + try { + const now = await readStatus(new JiraClient("read"), issueIdOrKey); + + return sameStatus(now, want) + ? `Verified: reading ${issueIdOrKey} back shows status ${now}.` + : `Could not verify: reading ${issueIdOrKey} back shows status ${now}, not ${want}. ` + + "A workflow post-function may have moved it on; check the issue's history in Jira."; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Could not verify the transition by reading ${issueIdOrKey} back: ${message}`; + } +} + +async function handler(input: any): Promise { + const { issueIdOrKey, status, dryRun } = validateInput( + TransitionIssueSchema, + input, + ); + + const readClient = new JiraClient("read"); + const current = await readStatus(readClient, issueIdOrKey); + + if (sameStatus(current, status)) { + return `${issueIdOrKey} is already in ${current}. Nothing to do.`; + } + + const transitions = await readClient.getTransitions(issueIdOrKey); + const transition = transitions.find((t) => sameStatus(t.to.name, status)); + + if (!transition) { + const reachable = [...new Set(transitions.map((t) => t.to.name))]; + + return [ + `No transition from ${current} to ${status} is available on ${issueIdOrKey}.`, + "", + reachable.length > 0 + ? `Statuses reachable from ${current}: ${reachable.join(", ")}.` + : `No transitions are available from ${current} for this user.`, + ].join("\n"); + } + + const target = transition.to.name; + + if (dryRun) { + const lines = [ + `# Dry run: transition ${issueIdOrKey}`, + "", + "No request was sent. Re-run with `dryRun: false` to apply this transition.", + "", + `Moves **${current}** -> **${target}** via transition "${transition.name}" (id ${transition.id}).`, + "", + "## Exact request", + "", + "```", + `POST /rest/api/3/issue/${issueIdOrKey}/transitions`, + "```", + "", + "```json", + JSON.stringify({ transition: { id: transition.id } }, null, 2), + "```", + "", + ]; + + if (!hasJiraWriteToken()) { + lines.push(...writeTokenDryRunNote("transition")); + } + + return lines.join("\n"); + } + + if (!hasJiraWriteToken()) { + return writeTokenRefusalMessage("transition"); + } + + try { + const client = new JiraClient("write"); + await client.transitionIssue(issueIdOrKey, transition.id); + + const verification = await verifyStatus(issueIdOrKey, target); + + return [ + `Transitioned **${issueIdOrKey}**: ${current} -> ${target}.`, + verification, + ].join("\n\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + return isWriteAuthError(message) + ? [`Error transitioning issue: ${message}`, "", writeScopeHint()].join( + "\n", + ) + : `Error transitioning issue: ${message}`; + } +} + +const transitionIssueTool: ToolDefinition = { + name: "transition_issue", + description: + "Move a Jira issue to a target status by name (e.g. 'In Progress', 'Done'). " + + "Finds the workflow transition that reaches that status from the current one; " + + "an issue already in the target status is left alone. Defaults to a dry run " + + "that reports the transition it would apply. Requires " + + "ATLASSIAN_JIRA_WRITE_TOKEN for a live transition.", + inputSchema: { + type: "object", + properties: { + issueIdOrKey: { + type: "string", + description: "Jira issue key, e.g. PM-12345.", + pattern: "^[A-Z][A-Z0-9_]+-\\d+$", + }, + status: { + type: "string", + description: + "Name of the status to move the issue to, matched case-insensitively.", + }, + dryRun: { + type: "boolean", + default: true, + description: + "When true (the default), returns the transition it would apply without sending it.", + }, + }, + required: ["issueIdOrKey", "status"], + }, + handler, +}; + +export default transitionIssueTool; diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/validation.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/validation.ts index 4e5a1350..ae5e0bf1 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/validation.ts +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/validation.ts @@ -338,6 +338,18 @@ export const AddIssueCommentSchema = z.object({ export type AddIssueCommentInput = z.infer; +/** + * Move an issue to a status by the status's name, not by transition id or + * transition name, so the caller never has to discover the workflow first. + */ +export const TransitionIssueSchema = z.object({ + issueIdOrKey: JiraIssueKey, + status: z.string().trim().min(1, "Target status cannot be empty"), + dryRun: z.boolean().optional().default(true), +}); + +export type TransitionIssueInput = z.infer; + /** * Validate input against a Zod schema * @param schema - Zod schema to validate against