Add Handbook.md benchmark tasks

This commit is contained in:
DerekSurge
2026-06-24 12:44:34 -07:00
commit 72f9a65917
1800 changed files with 323589 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# Jira Capabilities
A mock Jira project tracker with issues, sprints, workflow transitions, time tracking, watchers, comments, attachments, and issue linking.
## What the agent can do
**Create and manage issues.** Create issues (Stories, Tasks, Bugs, Epics, Sub-tasks), update fields (summary, description, priority, assignee, labels), delete, and search by JQL-style queries (with `~` fuzzy-match operator support). Search supports common equality, `IN`, `!=`, `NOT IN`, `IS EMPTY`, and date comparison filters for core fields including assignee, priority, status, statusCategory, resolution, parent, fixVersion, due, sprint, labels, and components. Browse issues by project or by epic. Link issues together (blocks, relates to, duplicates, clones).
**Workflow transitions.** Move issues through configured workflows. The default mock workflow is To Do → In Progress → In Review → Done (reopen also supported), and admin tools can add custom statuses or workflow transitions. The agent must use proper transitions — cannot jump directly from "To Do" to "Done" unless that transition exists. Query available transitions from the current status. Tests whether the agent understands process flow.
**Sprint management.** Discover seeded Scrum/Kanban boards, create admin-managed boards, create sprints on Scrum boards, view sprints and their issues, update sprint details (name, dates, state). Browse issues assigned to a specific sprint.
**Time tracking.** Log time spent on issues using Jira notation (e.g., "2h 30m", "1d 4h"). Set and update original and remaining time estimates. View all worklogs for an issue with a summary comparing estimated vs. actual time.
**Collaboration.** Add comments, attach files (with automatic MIME type detection), and manage watchers (add, remove, list who is watching an issue).
**User management.** Discover Jira users, inspect the currently authenticated user, and let admins create additional users for seeded worlds. Issue assignees, reporters, creators, watchers, comments, worklogs, and attachments reference state-level users by `accountId`. Tool-authored records use `currentUserAccountId`.
## Coverage gaps
- No board configuration tools beyond admin board creation; boards can also be seeded/imported
- No project creation or configuration
- No permissions or role-based access
- No notifications or @mentions
- No dashboard or reporting tools
- No bulk operations
- Sprint search uses configured fields named `Sprint`, with `customfield_10002` kept as the default mock convention
- No full workflow-screen or workflow-scheme modeling; custom status/transition tools cover the lightweight workflow cases
## Toolsets
34 tools total, including state import/export. `all` / `jira_all` contains the 32 non-state tools. Toolsets map to `WORLDBENCH_TOOL_SETS` values (prefixed form — e.g., `jira_issues`).
| Toolset | Tools | Description |
|---------|-------|-------------|
| `all` / `jira_all` | 32 | Everything |
| `read` / `jira_read` | 15 | All read-only tools |
| `write` / `jira_write` | 17 | All write tools |
| `jira_users` | 3 | Current-user lookup, user discovery, and admin user creation |
| `jira_issues` | 10 | Core issue CRUD: create, get, update, delete, search, project/epic issues, links, search fields |
| `jira_workflow` | 4 | Status transitions and workflow configuration: `create_status`, `get_transitions`, `transition_issue`, `upsert_workflow_transition` |
| `jira_sprints` | 6 | Sprint management: discover/create boards, create/update sprints, get sprints, get sprint issues |
| `jira_time` | 3 | Time tracking: add worklog, get worklogs, update estimate |
| `jira_collaboration` | 6 | Comments, watchers, attachments: add/get attachments, add comment, add/remove/get watchers |
| `jira_admin` | 7 | Admin-only operations: create users/boards, create/update sprint, delete issue, create statuses, configure workflow transitions |
| `jira_core` | 10 | Baseline work tracking: search, get/update issue, add comment, project issues, transitions, create issue, current-user/user lookup |
| `jira_toolathlon_legacy` | 15 | Legacy Toolathlon tool subset (pre-integration) |
| `jira_state` | 2 | `export_state`, `import_state` for fixture seeding and grading |
+36
View File
@@ -0,0 +1,36 @@
# Jira Mock MCP Server
Python mock Jira MCP server for offline Syntara tasks. It stores all service state in JSON and validates state with Pydantic models before writes.
## Run
```bash
uv run --package jira-mock python -m jira_mock
```
## Test
```bash
uv run pytest packages/jira/tests
```
## Available Tool Areas
- Issues: search, get, create, update, delete, project/epic issue listing, and issue linking.
- Workflow: list transitions, transition issues, create custom statuses, and define workflow transitions.
- Sprints: discover/create boards, create/update sprints on Scrum boards, list sprints, and inspect sprint issues.
- Time tracking: add worklogs, list worklogs, and update estimates.
- Collaboration: comments, watchers, and attachments.
- Users: inspect the current user, discover users, and admin-create additional users for assignment/watchers.
- State: `export_state` and `import_state` for fixture seeding and grading.
## Environment Variables
| Variable | Description |
|----------|-------------|
| `PORT` | HTTP server port. |
| `MCP_PROXY_TOKEN` | Optional proxy auth token for non-MCP viewer routes. |
| `AGENT_WORKSPACE` | Workspace root used for attachment path resolution. |
| `BUNDLEDIR` | Bundle directory used to locate seeded service state. |
| `BUNDLE_OUTPUT_DIR` | Snapshot output directory. |
| `OUTPUTDIR` | Legacy snapshot output directory fallback. |
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
{
"run": {"command": "python", "args": ["-m", "jira_mock"]},
"toolsets": {
"read": [
"get_attachments",
"get_boards",
"get_current_user",
"get_epic_issues",
"get_issue",
"get_link_types",
"get_project_issues",
"get_sprint_issues",
"get_sprints_from_board",
"get_transitions",
"get_users",
"get_watchers",
"get_worklogs",
"list_sites",
"search",
"search_fields"
],
"write": [
"add_attachment",
"add_comment",
"add_watcher",
"add_worklog",
"create_board",
"create_status",
"create_issue",
"create_sprint",
"create_user",
"delete_issue",
"link_issues",
"remove_watcher",
"transition_issue",
"update_estimate",
"update_issue",
"update_sprint",
"upsert_workflow_transition"
],
"users": [
"create_user",
"get_current_user",
"get_users"
],
"sites": [
"list_sites"
],
"issues": [
"create_issue",
"delete_issue",
"get_epic_issues",
"get_issue",
"get_link_types",
"get_project_issues",
"link_issues",
"search",
"search_fields",
"update_issue"
],
"workflow": [
"create_status",
"get_transitions",
"transition_issue",
"upsert_workflow_transition"
],
"sprints": [
"create_board",
"create_sprint",
"get_boards",
"get_sprint_issues",
"get_sprints_from_board",
"update_sprint"
],
"time": [
"add_worklog",
"get_worklogs",
"update_estimate"
],
"collaboration": [
"add_attachment",
"add_comment",
"add_watcher",
"get_attachments",
"get_watchers",
"remove_watcher"
],
"admin": [
"create_board",
"create_status",
"create_user",
"create_sprint",
"delete_issue",
"update_sprint",
"upsert_workflow_transition"
],
"core": [
"add_comment",
"create_issue",
"get_issue",
"get_project_issues",
"get_transitions",
"get_current_user",
"get_users",
"search",
"transition_issue",
"update_issue",
"create_sprint",
"delete_issue",
"get_epic_issues",
"get_link_types",
"get_sprint_issues",
"get_sprints_from_board",
"link_issues",
"search_fields",
"update_sprint"
],
"toolathlon_legacy": [
"add_comment",
"create_issue",
"create_sprint",
"delete_issue",
"get_epic_issues",
"get_boards",
"get_current_user",
"get_issue",
"get_link_types",
"get_project_issues",
"get_users",
"get_sprint_issues",
"get_sprints_from_board",
"link_issues",
"search",
"search_fields",
"update_issue",
"update_sprint"
],
"state": [
"export_state",
"import_state"
]
}
}
+21
View File
@@ -0,0 +1,21 @@
[build-system]
build-backend = "uv_build"
requires = [ "uv-build>=0.9.6,<0.10" ]
[project]
name = "jira-mock"
version = "1.0.0"
description = "Mock Jira MCP Server for testing and development"
requires-python = ">=3.13"
classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"fastmcp>=3,<4",
"pydantic[email]>=2.12.5",
"starlette>=0.50",
"uvicorn>=0.38",
]
scripts.jira-mock = "jira_mock:main"
@@ -0,0 +1,40 @@
"""Jira mock MCP server."""
from __future__ import annotations
import argparse
import logging
import os
from .server import mcp
from .state import init_state, set_agent_workspace
def main() -> None:
parser = argparse.ArgumentParser(description="Jira Mock MCP Server")
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
parser.add_argument("--agent-workspace", help="Agent workspace path used to resolve persistent Jira state")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
if args.agent_workspace:
set_agent_workspace(args.agent_workspace)
init_state()
from .async_tool_guard import assert_tools_async
assert_tools_async(mcp)
port = os.environ.get("PORT")
if port:
from jira_mock.viewer import run_http_server
run_http_server(mcp, int(port))
else:
mcp.run()
if __name__ == "__main__":
main()
@@ -0,0 +1,3 @@
from . import main
main()
@@ -0,0 +1,59 @@
"""Boot-time guard: every registered MCP tool must be async.
Sync (`def`) tools make FastMCP run pydantic argument validation in an anyio
worker threadpool. Under concurrent calls the shared pydantic-core validator is
re-entered across threads and panics with ``pyo3_runtime.PanicException:
dictionary changed size during iteration``, which tears down the
StreamableHTTP task group and turns every later request into a 500. Async
(`async def`) tools validate on the single-threaded event loop and are safe.
This guard runs at server boot (and therefore during ``mcp-proxy gen``, which
boots every server): a non-conformant package fails fast instead of shipping a
server that can crash under load. The same check is intentionally duplicated in
each package because the bundled prod images install only that package's own
dependencies, so there is no shared runtime module to import.
"""
from __future__ import annotations
import asyncio
import inspect
from functools import partial
from typing import Any
def _unwrap(fn: Any) -> Any:
while isinstance(fn, partial):
fn = fn.func
return fn
def find_sync_tools(mcp: Any) -> list[str]:
"""Return the names of registered tools whose function is not a coroutine."""
# mcp.server.fastmcp.FastMCP exposes a synchronous tool manager.
manager = getattr(mcp, "_tool_manager", None)
if manager is not None and hasattr(manager, "list_tools"):
return sorted(t.name for t in manager.list_tools() if not inspect.iscoroutinefunction(_unwrap(t.fn)))
# fastmcp.FastMCP exposes an async API.
async def _collect() -> list[str]:
sync: list[str] = []
for descriptor in await mcp.list_tools():
tool = await mcp.get_tool(descriptor.name)
if not inspect.iscoroutinefunction(_unwrap(tool.fn)):
sync.append(descriptor.name)
return sorted(sync)
return asyncio.run(_collect())
def assert_tools_async(mcp: Any) -> None:
"""Raise if any registered tool is synchronous."""
sync = find_sync_tools(mcp)
if sync:
raise RuntimeError(
"MCP tools must be async (`async def`). Sync tools run pydantic argument "
"validation in a worker threadpool and can trigger a pydantic-core "
"'dictionary changed size during iteration' panic under concurrent calls, "
"which kills the server. Make these tools async: " + ", ".join(sync)
)
@@ -0,0 +1,700 @@
"""Pydantic models for Jira mock state.
This first Python port intentionally mirrors the TypeScript server's permissive
state shape. The next pass can tighten IDs, enums, dates, and cross-reference
invariants once parity is stable.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Annotated, Any, Literal, Self
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, EmailStr, Field, StringConstraints, field_validator, model_validator
NonEmptyStateString = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
ShortNameString = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)]
NumericIdString = Annotated[str, StringConstraints(strip_whitespace=True, pattern=r"^\d+$")]
NonNegativeInt = Annotated[int, Field(ge=0)]
IssueKey = Annotated[str, StringConstraints(strip_whitespace=True, pattern=r"^[A-Z][A-Z0-9_]*-\d+$")]
ProjectKey = Annotated[str, StringConstraints(strip_whitespace=True, pattern=r"^[A-Z][A-Z0-9_]*$")]
AccountId = NonEmptyStateString
JiraSiteId = NonEmptyStateString
JiraAccountType = Literal["atlassian", "app", "customer"]
JiraTimeZone = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1),
Field(description="IANA time zone name, for example America/New_York."),
]
IssueTypeName = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=255),
Field(
description="Jira issue type name. Common defaults include Task, Bug, Story, Epic, and Sub-task; custom issue types are allowed.",
examples=["Task", "Bug", "Story", "Epic", "Sub-task"],
),
]
PriorityName = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=255),
Field(
description="Jira priority name. Common defaults include Highest, High, Medium, Low, and Lowest; custom priorities are allowed.",
examples=["Highest", "High", "Medium", "Low", "Lowest"],
),
]
JiraDateTime = Annotated[
str,
StringConstraints(
strip_whitespace=True,
pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$",
),
]
JiraTimeSpent = Annotated[str, StringConstraints(strip_whitespace=True, pattern=r"^(?:\d+\s*[wdhm]\s*)+$")]
Base64String = Annotated[
str,
StringConstraints(
strip_whitespace=True, pattern=r"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"
),
]
SprintState = Literal["active", "closed", "future"]
BoardType = Literal["scrum", "kanban"]
StatusCategoryKey = Literal["new", "indeterminate", "done", "undefined"]
AdfBlockType = Literal[
"paragraph",
"heading",
"bulletList",
"orderedList",
"listItem",
"codeBlock",
"blockquote",
"rule",
"table",
]
AdfInlineType = Literal["text", "hardBreak", "mention", "emoji", "inlineCard"]
class FlexibleModel(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True, validate_assignment=True)
@model_validator(mode="before")
@classmethod
def strip_fake_self_url(cls, data: Any) -> Any:
if isinstance(data, dict) and "self" in data:
data = {key: value for key, value in data.items() if key != "self"}
return data
class JiraUser(FlexibleModel):
accountId: AccountId
accountType: JiraAccountType = "atlassian"
emailAddress: EmailStr | None = None
displayName: NonEmptyStateString
active: bool = True
timeZone: JiraTimeZone | None = None
avatarUrls: dict[str, str] | None = None
@field_validator("timeZone")
@classmethod
def validate_time_zone(cls, value: str | None) -> str | None:
if value is None:
return None
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError(f"Unknown IANA time zone: {value}") from exc
return value
class JiraIssueType(FlexibleModel):
id: NumericIdString
name: IssueTypeName
description: str | None = None
iconUrl: str | None = None
subtask: bool = False
hierarchyLevel: int | None = None
class JiraStatusCategory(FlexibleModel):
id: int | str
key: StatusCategoryKey
name: NonEmptyStateString
colorName: NonEmptyStateString
class JiraStatus(FlexibleModel):
id: NumericIdString
name: NonEmptyStateString
description: str | None = None
iconUrl: str | None = None
statusCategory: JiraStatusCategory | None = None
class JiraPriority(FlexibleModel):
id: NumericIdString
name: PriorityName
description: str | None = None
iconUrl: str | None = None
class JiraProject(FlexibleModel):
id: NumericIdString
key: ProjectKey
name: NonEmptyStateString
description: str | None = None
projectTypeKey: str | None = None
simplified: bool | None = None
avatarUrls: dict[str, str] | None = None
class JiraBoard(FlexibleModel):
id: int
name: NonEmptyStateString
type: BoardType = "scrum"
projectKey: ProjectKey
filterJql: str | None = None
class JiraComponent(FlexibleModel):
id: NumericIdString | None = None
name: ShortNameString
description: str | None = None
lead: JiraUser | None = None
assigneeType: str | None = None
project: str | None = None
class JiraVersion(FlexibleModel):
id: NumericIdString | None = None
name: ShortNameString
description: str | None = None
archived: bool | None = None
released: bool | None = None
releaseDate: str | None = None
class JiraInlineContent(FlexibleModel):
type: AdfInlineType
text: str | None = None
marks: list[dict[str, Any]] | None = None
attrs: dict[str, Any] | None = None
class JiraContentBlock(FlexibleModel):
type: AdfBlockType
content: list[JiraInlineContent] | None = None
attrs: dict[str, Any] | None = None
class JiraDocumentContent(FlexibleModel):
type: Literal["doc"]
version: Literal[1]
content: list[JiraContentBlock] = Field(default_factory=list)
class JiraIssueLinkType(FlexibleModel):
id: NumericIdString
name: NonEmptyStateString
inward: NonEmptyStateString
outward: NonEmptyStateString
class JiraWorkflowTransitionConfig(FlexibleModel):
id: NumericIdString
name: ShortNameString
to: ShortNameString
class JiraLinkedIssueFields(FlexibleModel):
summary: NonEmptyStateString
status: JiraStatus
issuetype: JiraIssueType
class JiraLinkedIssue(FlexibleModel):
id: NumericIdString
key: IssueKey
fields: JiraLinkedIssueFields | None = None
class JiraIssueLink(FlexibleModel):
id: NonEmptyStateString
type: JiraIssueLinkType
inwardIssue: JiraLinkedIssue | None = None
outwardIssue: JiraLinkedIssue | None = None
class JiraComment(FlexibleModel):
id: NumericIdString
author: JiraUser
body: JiraDocumentContent | str
created: JiraDateTime
updated: JiraDateTime
updateAuthor: JiraUser | None = None
class JiraWorklog(FlexibleModel):
id: NumericIdString
author: JiraUser
updateAuthor: JiraUser
comment: JiraDocumentContent | str | None = None
created: JiraDateTime
updated: JiraDateTime
started: JiraDateTime
timeSpent: JiraTimeSpent
timeSpentSeconds: NonNegativeInt
class JiraAttachment(FlexibleModel):
id: NumericIdString
filename: NonEmptyStateString
author: JiraUser
created: JiraDateTime
size: NonNegativeInt
mimeType: NonEmptyStateString
content: Base64String
thumbnail: str | None = None
class JiraCommentPage(FlexibleModel):
comments: list[JiraComment] = Field(default_factory=list)
maxResults: NonNegativeInt
total: NonNegativeInt
startAt: NonNegativeInt = 0
class JiraWorklogPage(FlexibleModel):
worklogs: list[JiraWorklog] = Field(default_factory=list)
maxResults: NonNegativeInt
total: NonNegativeInt
startAt: NonNegativeInt = 0
class JiraWatches(FlexibleModel):
watchCount: NonNegativeInt = 0
isWatching: bool = False
watchers: list[JiraUser] = Field(default_factory=list)
@model_validator(mode="after")
def validate_watch_count_matches_watchers(self) -> Self:
if self.watchCount != len(self.watchers):
raise ValueError("watchCount must match number of watchers")
return self
class JiraTimeTracking(FlexibleModel):
originalEstimate: JiraTimeSpent | None = None
remainingEstimate: JiraTimeSpent | None = None
timeSpent: JiraTimeSpent | None = None
originalEstimateSeconds: NonNegativeInt | None = None
remainingEstimateSeconds: NonNegativeInt | None = None
timeSpentSeconds: NonNegativeInt | None = None
class JiraChangelogItem(FlexibleModel):
field: NonEmptyStateString
fieldtype: NonEmptyStateString | None = None
fieldId: NonEmptyStateString | None = None
from_: str | None = Field(default=None, alias="from")
fromString: str | None = None
to: str | None = None
toString: str | None = None
class JiraChangelogHistory(FlexibleModel):
id: NumericIdString
author: JiraUser | None = None
created: JiraDateTime
items: list[JiraChangelogItem] = Field(default_factory=list)
class JiraChangelog(FlexibleModel):
histories: list[JiraChangelogHistory] = Field(default_factory=list)
maxResults: NonNegativeInt | None = None
total: NonNegativeInt | None = None
startAt: NonNegativeInt | None = None
class JiraTransition(FlexibleModel):
id: NumericIdString
name: ShortNameString
to: JiraStatus
hasScreen: bool = False
isGlobal: bool = False
isInitial: bool = False
isAvailable: bool = True
isConditional: bool = False
isLooped: bool = False
class JiraFieldSchema(FlexibleModel):
type: NonEmptyStateString
system: NonEmptyStateString | None = None
custom: NonEmptyStateString | None = None
customId: NonNegativeInt | None = None
items: NonEmptyStateString | None = None
class JiraIssueFields(FlexibleModel):
summary: NonEmptyStateString
description: JiraDocumentContent | str | None = None
issuetype: JiraIssueType
project: JiraProject
status: JiraStatus
priority: JiraPriority | None = None
assignee: JiraUser | None = None
reporter: JiraUser | None = None
creator: JiraUser | None = None
created: JiraDateTime
updated: JiraDateTime
resolutiondate: JiraDateTime | None = None
labels: list[str] = Field(default_factory=list)
components: list[JiraComponent] = Field(default_factory=list)
fixVersions: list[JiraVersion] = Field(default_factory=list)
versions: list[JiraVersion] = Field(default_factory=list)
parent: JiraLinkedIssue | None = None
subtasks: list[JiraLinkedIssue] | None = None
issuelinks: list[JiraIssueLink] | None = None
comment: JiraCommentPage | None = None
worklog: JiraWorklogPage | None = None
attachment: list[JiraAttachment] | None = None
watches: JiraWatches | None = None
timetracking: JiraTimeTracking | None = None
class JiraIssue(FlexibleModel):
id: NumericIdString
key: IssueKey
expand: str | None = None
fields: JiraIssueFields
renderedFields: dict[str, Any] | None = None
changelog: JiraChangelog | None = None
transitions: list[JiraTransition] | None = None
class JiraSprint(FlexibleModel):
id: int
state: SprintState
name: NonEmptyStateString
startDate: JiraDateTime | None = None
endDate: JiraDateTime | None = None
completeDate: JiraDateTime | None = None
originBoardId: int
goal: str | None = None
class JiraField(FlexibleModel):
id: NonEmptyStateString
key: NonEmptyStateString
name: NonEmptyStateString
custom: bool
orderable: bool | None = None
navigable: bool | None = None
searchable: bool | None = None
clauseNames: list[str] | None = None
schema_: JiraFieldSchema | None = Field(default=None, alias="schema")
class JiraCounters(FlexibleModel):
issueId: int = 10000
sprintId: int = 1000
boardId: int = 1001
commentId: int = 0
worklogId: int = 0
attachmentId: int = 0
issueLinkId: int = 0
class JiraState(FlexibleModel):
is_admin: bool = False
defaultStatusValue: ShortNameString = "To Do"
currentUserAccountId: AccountId | None = None
users: dict[AccountId, JiraUser] = Field(default_factory=dict)
issues: dict[str, JiraIssue] = Field(default_factory=dict)
sprints: dict[str, JiraSprint] = Field(default_factory=dict)
comments: dict[str, list[JiraComment]] = Field(default_factory=dict)
worklogs: dict[str, list[JiraWorklog]] = Field(default_factory=dict)
projects: dict[str, JiraProject] = Field(default_factory=dict)
boards: dict[str, JiraBoard] = Field(default_factory=dict)
fields: list[JiraField] = Field(default_factory=list)
linkTypes: list[JiraIssueLinkType] = Field(default_factory=list)
statuses: dict[NumericIdString, JiraStatus] = Field(default_factory=dict)
workflow: dict[ShortNameString, list[JiraWorkflowTransitionConfig]] = Field(default_factory=dict)
counters: JiraCounters = Field(default_factory=JiraCounters)
@model_validator(mode="before")
@classmethod
def populate_users_from_legacy_embedded_objects(cls, data: Any) -> Any:
if not isinstance(data, dict) or "users" in data:
return data
users: dict[str, Any] = {}
def add_user(value: Any) -> None:
if isinstance(value, dict) and isinstance(value.get("accountId"), str):
users.setdefault(value["accountId"], value)
for issue in (data.get("issues") or {}).values():
if not isinstance(issue, dict):
continue
fields = issue.get("fields") or {}
if not isinstance(fields, dict):
continue
for field in ("assignee", "reporter", "creator"):
add_user(fields.get(field))
for component in fields.get("components") or []:
if isinstance(component, dict):
add_user(component.get("lead"))
for attachment in fields.get("attachment") or []:
if isinstance(attachment, dict):
add_user(attachment.get("author"))
watches = fields.get("watches") or {}
if isinstance(watches, dict):
for watcher in watches.get("watchers") or []:
add_user(watcher)
changelog = issue.get("changelog") or {}
if isinstance(changelog, dict):
for history in changelog.get("histories") or []:
if isinstance(history, dict):
add_user(history.get("author"))
for comments in (data.get("comments") or {}).values():
for comment in comments or []:
if isinstance(comment, dict):
add_user(comment.get("author"))
add_user(comment.get("updateAuthor"))
for worklogs in (data.get("worklogs") or {}).values():
for worklog in worklogs or []:
if isinstance(worklog, dict):
add_user(worklog.get("author"))
add_user(worklog.get("updateAuthor"))
if users:
return {**data, "users": users}
return data
@model_validator(mode="after")
def validate_keys_and_issue_references(self) -> Self:
def require_unique(label: str, pairs: Iterable[tuple[str, str]]) -> None:
seen: dict[str, str] = {}
for owner, value in pairs:
if value in seen:
raise ValueError(f"duplicate {label} id {value!r} on {owner!r} and {seen[value]!r}")
seen[value] = owner
for key, issue in self.issues.items():
if key != issue.key:
raise ValueError(f"issues key {key!r} does not match issue.key {issue.key!r}")
for key, user in self.users.items():
if key != user.accountId:
raise ValueError(f"users key {key!r} does not match user.accountId {user.accountId!r}")
if self.currentUserAccountId is not None and self.currentUserAccountId not in self.users:
raise ValueError(f"currentUserAccountId {self.currentUserAccountId!r} does not reference an existing user")
for key, project in self.projects.items():
if key != project.key:
raise ValueError(f"projects key {key!r} does not match project.key {project.key!r}")
for key, sprint in self.sprints.items():
if key != str(sprint.id):
raise ValueError(f"sprints key {key!r} does not match sprint.id {sprint.id!r}")
for key, board in self.boards.items():
if key != str(board.id):
raise ValueError(f"boards key {key!r} does not match board.id {board.id!r}")
for key, status in self.statuses.items():
if key != status.id:
raise ValueError(f"statuses key {key!r} does not match status.id {status.id!r}")
if self.statuses or self.workflow:
status_by_name: dict[str, JiraStatus] = {}
for status in self.statuses.values():
normalized_name = status.name.lower()
if normalized_name in status_by_name:
raise ValueError(
f"duplicate status name {status.name!r} on status ids {status.id!r} and {status_by_name[normalized_name].id!r}"
)
status_by_name[normalized_name] = status
def require_configured_status(name: str, owner: str) -> JiraStatus:
status = status_by_name.get(name.lower())
if status is None:
raise ValueError(f"{owner} references missing status {name!r}")
return status
default_status = status_by_name.get(self.defaultStatusValue.lower())
if default_status is None:
raise ValueError(
f"defaultStatusValue {self.defaultStatusValue!r} does not reference a configured status"
)
canonical_workflow: dict[ShortNameString, list[JiraWorkflowTransitionConfig]] = {}
for from_status_name, transitions in self.workflow.items():
from_status = require_configured_status(from_status_name, f"workflow key {from_status_name!r}")
if from_status.name in canonical_workflow:
raise ValueError(f"duplicate workflow entry for status {from_status.name!r}")
for transition in transitions:
to_status = require_configured_status(
transition.to, f"workflow transition {transition.id!r} from {from_status.name!r}"
)
transition.to = to_status.name
canonical_workflow[from_status.name] = transitions
if default_status.name not in canonical_workflow:
raise ValueError(f"defaultStatusValue {default_status.name!r} does not have a workflow entry")
object.__setattr__(self, "defaultStatusValue", default_status.name)
object.__setattr__(self, "workflow", canonical_workflow)
for issue in self.issues.values():
status = require_configured_status(issue.fields.status.name, f"issue {issue.key!r} status")
issue.fields.status = status
require_unique("project", [(project.key, project.id) for project in self.projects.values()])
require_unique("issue", [(issue.key, issue.id) for issue in self.issues.values()])
require_unique("sprint", [(key, str(sprint.id)) for key, sprint in self.sprints.items()])
require_unique("board", [(board.name, str(board.id)) for board in self.boards.values()])
require_unique("status", [(status.name, status.id) for status in self.statuses.values()])
require_unique("field", [(field.key, field.id) for field in self.fields])
require_unique("linkType", [(link_type.name, link_type.id) for link_type in self.linkTypes])
require_unique(
"comment",
[
(f"{issue_key}:{comment.id}", comment.id)
for issue_key, comments in self.comments.items()
for comment in comments
],
)
require_unique(
"worklog",
[
(f"{issue_key}:{worklog.id}", worklog.id)
for issue_key, worklogs in self.worklogs.items()
for worklog in worklogs
],
)
require_unique(
"attachment",
(
(f"{issue.key}:{attachment.id}", attachment.id)
for issue in self.issues.values()
for attachment in issue.fields.attachment or []
),
)
require_unique(
"issueLink",
(
(f"{issue.key}:{link.id}", link.id)
for issue in self.issues.values()
for link in issue.fields.issuelinks or []
),
)
def canonical_user(owner: str, user: JiraUser | None) -> JiraUser | None:
if user is None:
return None
canonical = self.users.get(user.accountId)
if canonical is None:
raise ValueError(f"{owner} references missing user {user.accountId!r}")
return canonical
def canonical_required_user(owner: str, user: JiraUser) -> JiraUser:
canonical = canonical_user(owner, user)
if canonical is None:
raise ValueError(f"{owner} references missing user")
return canonical
for key in self.comments:
if key not in self.issues:
raise ValueError(f"comments key {key!r} does not reference an existing issue")
for comment in self.comments[key]:
comment.author = canonical_required_user(f"comment {comment.id!r} author", comment.author)
comment.updateAuthor = canonical_user(f"comment {comment.id!r} updateAuthor", comment.updateAuthor)
for key in self.worklogs:
if key not in self.issues:
raise ValueError(f"worklogs key {key!r} does not reference an existing issue")
for worklog in self.worklogs[key]:
worklog.author = canonical_required_user(f"worklog {worklog.id!r} author", worklog.author)
worklog.updateAuthor = canonical_required_user(
f"worklog {worklog.id!r} updateAuthor", worklog.updateAuthor
)
for key, board in self.boards.items():
if board.projectKey not in self.projects:
raise ValueError(f"board {key!r} references missing project {board.projectKey!r}")
for key, sprint in self.sprints.items():
board = self.boards.get(str(sprint.originBoardId))
if board is None:
raise ValueError(f"sprint {key!r} references missing board {sprint.originBoardId!r}")
if board.type != "scrum":
raise ValueError(f"sprint {key!r} references non-scrum board {board.id!r}")
for key, issue in self.issues.items():
issue_project_key = issue.fields.project.key
if key.split("-", 1)[0] != issue_project_key:
raise ValueError(f"issue {key!r} key prefix does not match project {issue_project_key!r}")
project = self.projects.get(issue_project_key)
if project is None:
raise ValueError(f"issue {key!r} project references missing project {issue_project_key!r}")
if project.id != issue.fields.project.id:
raise ValueError(
f"issue {key!r} project id {issue.fields.project.id!r} does not match state project id {project.id!r}"
)
issue.fields.project = project
issue.fields.assignee = canonical_user(f"issue {key!r} assignee", issue.fields.assignee)
issue.fields.reporter = canonical_user(f"issue {key!r} reporter", issue.fields.reporter)
issue.fields.creator = canonical_user(f"issue {key!r} creator", issue.fields.creator)
for component in issue.fields.components:
component.lead = canonical_user(f"issue {key!r} component {component.name!r} lead", component.lead)
for attachment in issue.fields.attachment or []:
attachment.author = canonical_required_user(
f"issue {key!r} attachment {attachment.id!r} author", attachment.author
)
if issue.fields.watches is not None:
issue.fields.watches.watchers = [
canonical_required_user(f"issue {key!r} watcher", watcher)
for watcher in issue.fields.watches.watchers
]
if issue.changelog is not None:
for history in issue.changelog.histories:
history.author = canonical_user(
f"issue {key!r} changelog history {history.id!r} author", history.author
)
if issue.fields.parent is not None and issue.fields.parent.key not in self.issues:
raise ValueError(f"issue {key!r} parent references missing issue {issue.fields.parent.key!r}")
for subtask in issue.fields.subtasks or []:
if subtask.key not in self.issues:
raise ValueError(f"issue {key!r} subtask references missing issue {subtask.key!r}")
for link in issue.fields.issuelinks or []:
if link.inwardIssue is not None and link.inwardIssue.key not in self.issues:
raise ValueError(f"issue {key!r} inward link references missing issue {link.inwardIssue.key!r}")
if link.outwardIssue is not None and link.outwardIssue.key not in self.issues:
raise ValueError(f"issue {key!r} outward link references missing issue {link.outwardIssue.key!r}")
return self
class JiraSitesState(FlexibleModel):
sites: dict[JiraSiteId, JiraState] = Field(default_factory=dict)
@model_validator(mode="after")
def _has_sites(self) -> Self:
if not self.sites:
raise ValueError("sites must contain at least one Jira site")
return self
JiraMockState = JiraState | JiraSitesState
@@ -0,0 +1,490 @@
"""FastMCP Jira mock server."""
from __future__ import annotations
import functools
import inspect
from collections.abc import Callable
from typing import Annotated, Any
from fastmcp import FastMCP
from pydantic import EmailStr, Field
from jira_mock.models import (
AccountId,
Base64String,
BoardType,
IssueTypeName,
JiraDateTime,
JiraTimeSpent,
JiraTimeZone,
NumericIdString,
ShortNameString,
SprintState,
StatusCategoryKey,
)
from jira_mock.state import set_active_site, write_snapshots
from jira_mock.tools import collaboration, issues, sprints, time, users, workflow
from jira_mock.tools import state as state_tools
from jira_mock.tools.common import (
DEFAULT_READ_JIRA_FIELDS,
BoardIdArg,
IssueKey,
LimitArg,
ProjectKeyArg,
SiteIdArg,
SprintIdArg,
StartAtArg,
TransitionIdArg,
)
mcp = FastMCP("jira-mock-service")
def _snapshot_on_write(fn: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
result = fn(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
write_snapshots()
return result
return wrapper
def _with_site(fn: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
site_id = kwargs.pop("site_id", "default")
set_active_site(site_id)
result = fn(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
return result
return wrapper
@mcp.tool()
@_with_site
def search(
jql: Annotated[str, Field(description="JQL query string")],
fields: Annotated[str, Field(description="Comma-separated fields to return")] = DEFAULT_READ_JIRA_FIELDS,
limit: Annotated[int | None, Field(description="Maximum number of results (1-50)")] = 10,
startAt: Annotated[int | None, Field(description="Starting index for pagination")] = 0,
projects_filter: Annotated[str | None, Field(description="Comma-separated project keys to filter")] = None,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Search Jira issues using the mock's supported JQL subset.
Supported filters include project, key, status, assignee, reporter, priority,
issue type, labels, components, sprint/customfield_10002, fixVersion,
statusCategory, parent, created, updated, due/duedate, summary ~,
description ~, and text ~. Equality, inequality, IN, NOT IN, IS EMPTY,
IS NOT EMPTY, date comparisons, top-level OR, and ORDER BY are supported.
Unsupported JQL fields/operators are reported in warningMessages and may be
ignored rather than treated as hard errors. Parenthesized OR clauses are not
supported and return no results with a warning. Use fields to request a
comma-separated field subset, or *all for the full issue fields.
"""
return issues.search(jql, fields, limit, startAt, projects_filter)
@mcp.tool()
@_with_site
def get_issue(
issue_key: IssueKey,
fields: Annotated[str, Field(description="Fields to return")] = DEFAULT_READ_JIRA_FIELDS,
expand: Annotated[str | None, Field(description="Optional fields to expand")] = None,
comment_limit: Annotated[int, Field(description="Maximum number of comments")] = 10,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Get details of a specific Jira issue."""
return issues.get_issue(issue_key, fields, expand, comment_limit)
@mcp.tool()
@_with_site
def get_project_issues(
project_key: ProjectKeyArg,
limit: LimitArg = 10,
startAt: StartAtArg = 0,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Get all issues for a specific Jira project."""
return issues.get_project_issues(project_key, limit, startAt)
@mcp.tool()
@_with_site
def get_epic_issues(
epic_key: IssueKey,
limit: LimitArg = 10,
startAt: StartAtArg = 0,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Get all issues linked to a specific epic."""
return issues.get_epic_issues(epic_key, limit, startAt)
@mcp.tool()
@_with_site
@_snapshot_on_write
def create_issue(
project_key: ProjectKeyArg,
summary: str,
issue_type: IssueTypeName,
assignee: str | None = None,
description: str = "",
components: str = "",
additional_fields: str = "{}",
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Create a new Jira issue."""
return issues.create_issue(project_key, summary, issue_type, assignee, description, components, additional_fields)
@mcp.tool()
@_with_site
@_snapshot_on_write
def update_issue(
issue_key: IssueKey,
fields: Annotated[
str,
Field(
description=(
"JSON object string of editable issue fields. Supports summary, description, priority, assignee, "
'and labels. Example: {"summary":"New title","description":"Updated details","labels":["backend"]}. '
"Do not use this for status changes; use get_transitions and transition_issue instead."
)
),
],
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Update editable Jira issue fields. Use transition_issue for status/workflow changes."""
return issues.update_issue(issue_key, fields)
@mcp.tool()
@_with_site
@_snapshot_on_write
def delete_issue(issue_key: IssueKey, site_id: SiteIdArg = "default") -> dict[str, str]:
"""Delete an existing Jira issue."""
return issues.delete_issue(issue_key)
@mcp.tool()
@_with_site
@_snapshot_on_write
def link_issues(
inward_issue_key: IssueKey,
outward_issue_key: IssueKey,
link_type: str,
comment: str | None = None,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Create a bidirectional link between two Jira issues.
link_type may be a configured link type name or one of its inward/outward
labels, such as Blocks, blocks, or is blocked by. Inward labels resolve to
the same canonical link type with direction reversed. Default link types are
Blocks, Cloners, Duplicate, and Relates; call get_link_types to inspect the
current world's configured link types.
"""
return issues.link_issues(inward_issue_key, outward_issue_key, link_type, comment)
@mcp.tool()
@_with_site
def search_fields(
keyword: str = "",
limit: LimitArg = 10,
refresh: bool = False,
site_id: SiteIdArg = "default",
) -> list[dict[str, Any]]:
"""Search Jira fields by keyword."""
return issues.search_fields(keyword, limit, refresh)
@mcp.tool()
@_with_site
def get_link_types(site_id: SiteIdArg = "default") -> dict[str, Any]:
"""Get all available issue link types."""
return issues.get_link_types()
@mcp.tool()
@_with_site
def get_transitions(issue_key: IssueKey, site_id: SiteIdArg = "default") -> dict[str, Any]:
"""Get available workflow transitions for an issue."""
return workflow.get_transitions(issue_key)
@mcp.tool()
@_with_site
@_snapshot_on_write
def transition_issue(
issue_key: IssueKey, transition_id: TransitionIdArg, site_id: SiteIdArg = "default"
) -> dict[str, Any]:
"""Move an issue through a workflow transition.
transition_id must be one of the transitions currently available for the
issue's status. Call get_transitions(issue_key) immediately before this tool
to discover valid transition IDs and their target statuses.
"""
return workflow.transition_issue(issue_key, transition_id)
@mcp.tool()
@_with_site
@_snapshot_on_write
def create_user(
account_id: AccountId,
display_name: ShortNameString,
email_address: EmailStr | None = None,
active: bool = True,
time_zone: JiraTimeZone | None = "America/New_York",
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Create a Jira user. Requires is_admin=true."""
return users.create_user(account_id, display_name, email_address, active, time_zone)
@mcp.tool()
@_with_site
def get_users(
query: str = "",
active: bool | None = None,
startAt: StartAtArg = 0,
limit: LimitArg = 10,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Get Jira users, optionally filtered by query text or active status."""
return users.get_users(query, active, startAt, limit)
@mcp.tool()
@_with_site
def get_current_user(site_id: SiteIdArg = "default") -> dict[str, Any]:
"""Get the Jira user whose account is currently authenticated for tool calls."""
return users.get_current_user()
@mcp.tool()
@_with_site
@_snapshot_on_write
def create_status(
status_id: NumericIdString,
name: ShortNameString,
status_category: StatusCategoryKey,
color_name: ShortNameString | None = None,
description: str | None = None,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Create a Jira status. Requires is_admin=true in imported state."""
return workflow.create_status(status_id, name, status_category, color_name, description)
@mcp.tool()
@_with_site
@_snapshot_on_write
def upsert_workflow_transition(
from_status: ShortNameString,
transition_id: NumericIdString,
transition_name: ShortNameString,
to_status: ShortNameString,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Create or update a workflow transition between configured Jira statuses. Requires is_admin=true."""
return workflow.upsert_workflow_transition(from_status, transition_id, transition_name, to_status)
@mcp.tool()
@_with_site
@_snapshot_on_write
def add_comment(issue_key: IssueKey, comment: str, site_id: SiteIdArg = "default") -> dict[str, Any]:
"""Add a comment to a Jira issue."""
return collaboration.add_comment(issue_key, comment)
@mcp.tool()
@_with_site
@_snapshot_on_write
def create_board(
project_key: ProjectKeyArg,
name: ShortNameString,
board_type: BoardType = "scrum",
filter_jql: str | None = None,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Create a Jira board for an existing project. Requires is_admin=true."""
return sprints.create_board(project_key, name, board_type, filter_jql)
@mcp.tool()
@_with_site
def get_boards(
project_key: ProjectKeyArg | None = None,
board_type: BoardType | None = None,
startAt: StartAtArg = 0,
limit: LimitArg = 10,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Get Jira boards, optionally filtered by project key or board type."""
return sprints.get_boards(project_key, board_type, startAt, limit)
@mcp.tool()
@_with_site
def get_sprints_from_board(
board_id: BoardIdArg = "1000",
state: SprintState | None = None,
startAt: StartAtArg = 0,
limit: LimitArg = 10,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Get Jira sprints from board by state."""
return sprints.get_sprints_from_board(board_id, state, startAt, limit)
@mcp.tool()
@_with_site
@_snapshot_on_write
def create_sprint(
board_id: BoardIdArg,
sprint_name: str,
start_date: JiraDateTime,
end_date: JiraDateTime,
goal: str | None = None,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Create Jira sprint for a board."""
return sprints.create_sprint(board_id, sprint_name, start_date, end_date, goal)
@mcp.tool()
@_with_site
def get_sprint_issues(
sprint_id: SprintIdArg,
fields: str = DEFAULT_READ_JIRA_FIELDS,
startAt: StartAtArg = 0,
limit: LimitArg = 10,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Get Jira issues from sprint."""
return sprints.get_sprint_issues(sprint_id, fields, startAt, limit)
@mcp.tool()
@_with_site
@_snapshot_on_write
def update_sprint(
sprint_id: SprintIdArg,
sprint_name: str | None = None,
state: SprintState | None = None,
start_date: JiraDateTime | None = None,
end_date: JiraDateTime | None = None,
goal: str | None = None,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Update Jira sprint."""
return sprints.update_sprint(sprint_id, sprint_name, state, start_date, end_date, goal)
@mcp.tool()
@_with_site
@_snapshot_on_write
def add_worklog(
issue_key: IssueKey,
time_spent: JiraTimeSpent,
comment: str | None = None,
started: JiraDateTime | None = None,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Log time spent on a Jira issue."""
return time.add_worklog(issue_key, time_spent, comment, started)
@mcp.tool()
@_with_site
def get_worklogs(issue_key: IssueKey, site_id: SiteIdArg = "default") -> dict[str, Any]:
"""Get all work logs for a Jira issue."""
return time.get_worklogs(issue_key)
@mcp.tool()
@_with_site
@_snapshot_on_write
def update_estimate(
issue_key: IssueKey,
original_estimate: JiraTimeSpent,
remaining_estimate: JiraTimeSpent | None = None,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Set or update time estimates for an issue."""
return time.update_estimate(issue_key, original_estimate, remaining_estimate)
@mcp.tool()
@_with_site
@_snapshot_on_write
def add_watcher(issue_key: IssueKey, account_id: AccountId, site_id: SiteIdArg = "default") -> dict[str, str]:
"""Add a watcher to a Jira issue."""
return collaboration.add_watcher(issue_key, account_id)
@mcp.tool()
@_with_site
@_snapshot_on_write
def remove_watcher(issue_key: IssueKey, account_id: AccountId, site_id: SiteIdArg = "default") -> dict[str, str]:
"""Remove a watcher from a Jira issue."""
return collaboration.remove_watcher(issue_key, account_id)
@mcp.tool()
@_with_site
def get_watchers(issue_key: IssueKey, site_id: SiteIdArg = "default") -> dict[str, Any]:
"""Get all watchers of a Jira issue."""
return collaboration.get_watchers(issue_key)
@mcp.tool()
@_with_site
@_snapshot_on_write
def add_attachment(
issue_key: IssueKey,
filename: str,
content_base64: Base64String,
mime_type: str | None = None,
site_id: SiteIdArg = "default",
) -> dict[str, Any]:
"""Attach a base64-encoded file to a Jira issue."""
return collaboration.add_attachment(issue_key, filename, content_base64, mime_type)
@mcp.tool()
@_with_site
def get_attachments(issue_key: IssueKey, site_id: SiteIdArg = "default") -> dict[str, Any]:
"""List all attachments on a Jira issue."""
return collaboration.get_attachments(issue_key)
@mcp.tool()
async def list_sites() -> dict[str, Any]:
"""List available Jira sites in the loaded mock state."""
return state_tools.list_sites()
@mcp.tool()
async def export_state() -> dict[str, Any]:
"""Export the full Jira state as JSON."""
return state_tools.export_state()
@mcp.tool()
@_snapshot_on_write
def import_state(state: dict[str, Any]) -> dict[str, bool]:
"""Replace the full Jira state with provided JSON."""
return state_tools.import_state(state)
+769
View File
@@ -0,0 +1,769 @@
"""State management for the Jira mock server."""
from __future__ import annotations
import json
import logging
import os
import re
from copy import deepcopy
from pathlib import Path
from typing import Any, cast
from jira_mock.models import JiraIssue, JiraMockState, JiraProject, JiraSitesState, JiraState, JiraStatus
_logger = logging.getLogger(__name__)
SERVICE_NAME = "jira"
_state_file: Path | None = None
_sites: dict[str, JiraState] = {}
_active_site_id = "default"
_current_state: JiraState | None = None
_bundle_state_path: Path | None = None
_final_path: Path | None = None
_UNSET = object()
def resolve_bundle_state_paths() -> list[Path]:
"""Resolve the seed-state files inside this service's bundle subdir.
The folder ``<BUNDLEDIR>/services/<name>/`` is the unit: everything in it
is this service's seed. Prefer the canonical single-file ``state.json``
(the output round-trip shape); otherwise hand back ALL ``*.json`` in the
folder (the raw entities layout), coalesced by the loader.
"""
bundle_dir = os.environ.get("BUNDLEDIR")
if not bundle_dir:
return []
service_dir = Path(bundle_dir) / "services" / SERVICE_NAME
state_file = service_dir / "state.json"
if state_file.is_file():
return [state_file]
if service_dir.is_dir():
return sorted(service_dir.glob("*.json"))
return []
def resolve_bundle_state_path() -> Path | None:
"""Back-compat single-file view of :func:`resolve_bundle_state_paths`."""
paths = resolve_bundle_state_paths()
return paths[0] if paths else None
def _merge_flat_into(target: dict[str, Any], source: dict[str, Any]) -> None:
"""Merge a flat site seed into ``target``: dicts update, lists extend, scalars overwrite."""
for key, value in source.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
target[key].update(value)
elif isinstance(value, list) and isinstance(target.get(key), list):
target[key].extend(value)
else:
target[key] = value
def _coalesce_site_files(paths: list[Path]) -> dict[str, Any] | None:
"""Coalesce a folder of seed files into one seed dict.
A single file passes through unchanged (flat single-site or ``{sites:
{...}}`` wrapper). With multiple files, flat (non-wrapper) files are merged
into ONE ``default`` site — the raw entities layout splits one site
across per-entity files, and those belong together, not in separate sites.
Files carrying an explicit ``{sites: {...}}`` wrapper contribute their
named sites.
"""
if not paths:
return None
if len(paths) == 1:
# Single file: pass through unchanged so a flat seed stays flat and a
# wrapper stays a wrapper (back-compat + canonical state.json).
return json.loads(paths[0].read_text(encoding="utf-8"))
sites: dict[str, Any] = {}
default_site: dict[str, Any] = {}
for path in paths:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict) and "sites" in data:
sites.update(data["sites"])
elif isinstance(data, dict):
_merge_flat_into(default_site, data)
if default_site:
sites.setdefault("default", default_site)
return {"sites": sites}
def resolve_bundle_output_path() -> Path | None:
output_dir = os.environ.get("BUNDLE_OUTPUT_DIR")
if not output_dir:
return None
return Path(output_dir) / "state.json"
def get_jira_state_path(agent_workspace: str) -> Path:
return Path(agent_workspace).parent / "external_services" / "jira_state.json"
def set_agent_workspace(agent_workspace: str) -> None:
global _current_state, _state_file
_state_file = get_jira_state_path(agent_workspace)
_state_file.parent.mkdir(parents=True, exist_ok=True)
_sites.clear()
_current_state = None
_logger.info("Jira state file: %s", _state_file)
def set_snapshot_paths(
*,
final_path: Path | str | None | object = _UNSET,
bundle_state_path: Path | str | None | object = _UNSET,
) -> None:
global _bundle_state_path, _final_path
if bundle_state_path is not _UNSET:
_bundle_state_path = Path(cast(str | Path, bundle_state_path)) if bundle_state_path is not None else None
if final_path is not _UNSET:
_final_path = Path(cast(str | Path, final_path)) if final_path is not None else None
def configure_snapshots_from_env() -> None:
bundle_state_path = resolve_bundle_output_path()
final_path = Path(output_dir) / "final.json" if (output_dir := os.environ.get("OUTPUTDIR")) else None
set_snapshot_paths(bundle_state_path=bundle_state_path, final_path=final_path)
def write_snapshots() -> None:
if _bundle_state_path is not None:
dump_state(_bundle_state_path, "bundle")
if _final_path is not None:
dump_state(_final_path, "final")
def _default_state_data() -> dict[str, Any]:
return {
"is_admin": False,
"currentUserAccountId": "reporter-001",
"users": {
"reporter-001": {
"accountId": "reporter-001",
"accountType": "atlassian",
"emailAddress": "reporter-001@example.com",
"displayName": "Reporter User",
"active": True,
"timeZone": "America/New_York",
},
"creator-001": {
"accountId": "creator-001",
"accountType": "atlassian",
"emailAddress": "creator-001@example.com",
"displayName": "Creator User",
"active": True,
"timeZone": "America/New_York",
},
"commenter-001": {
"accountId": "commenter-001",
"accountType": "atlassian",
"emailAddress": "commenter-001@example.com",
"displayName": "Commenter User",
"active": True,
"timeZone": "America/New_York",
},
"worker-001": {
"accountId": "worker-001",
"accountType": "atlassian",
"emailAddress": "worker-001@example.com",
"displayName": "Worker User",
"active": True,
"timeZone": "America/New_York",
},
"uploader-001": {
"accountId": "uploader-001",
"accountType": "atlassian",
"emailAddress": "uploader-001@example.com",
"displayName": "Uploader User",
"active": True,
"timeZone": "America/New_York",
},
"user-1": {
"accountId": "user-1",
"accountType": "atlassian",
"emailAddress": "user-1@example.com",
"displayName": "User 1",
"active": True,
"timeZone": "America/New_York",
},
},
"issues": {},
"sprints": {},
"comments": {},
"worklogs": {},
"projects": {
"MOCK": {
"id": "10001",
"key": "MOCK",
"name": "Mock Project",
"description": "Default mock project",
"projectTypeKey": "software",
"simplified": False,
},
"TEST": {
"id": "10002",
"key": "TEST",
"name": "Test Project",
"description": "Default test project",
"projectTypeKey": "software",
"simplified": False,
},
},
"boards": {
"1000": {
"id": 1000,
"name": "Mock Scrum Board",
"type": "scrum",
"projectKey": "MOCK",
"filterJql": "project = MOCK",
},
"1001": {
"id": 1001,
"name": "Test Scrum Board",
"type": "scrum",
"projectKey": "TEST",
"filterJql": "project = TEST",
},
},
"fields": [
{"id": "summary", "key": "summary", "name": "Summary", "custom": False, "searchable": True},
{"id": "description", "key": "description", "name": "Description", "custom": False, "searchable": True},
{"id": "status", "key": "status", "name": "Status", "custom": False, "searchable": True},
{"id": "priority", "key": "priority", "name": "Priority", "custom": False, "searchable": True},
{"id": "assignee", "key": "assignee", "name": "Assignee", "custom": False, "searchable": True},
{"id": "reporter", "key": "reporter", "name": "Reporter", "custom": False, "searchable": True},
{"id": "created", "key": "created", "name": "Created", "custom": False, "searchable": True},
{"id": "updated", "key": "updated", "name": "Updated", "custom": False, "searchable": True},
{"id": "labels", "key": "labels", "name": "Labels", "custom": False, "searchable": True},
{"id": "issuetype", "key": "issuetype", "name": "Issue Type", "custom": False, "searchable": True},
{
"id": "customfield_10001",
"key": "customfield_10001",
"name": "Story Points",
"custom": True,
"searchable": True,
},
{
"id": "customfield_10002",
"key": "customfield_10002",
"name": "Sprint",
"custom": True,
"searchable": True,
},
{
"id": "customfield_10003",
"key": "customfield_10003",
"name": "Epic Link",
"custom": True,
"searchable": True,
},
],
"linkTypes": [
{"id": "10001", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
{"id": "10002", "name": "Cloners", "inward": "is cloned by", "outward": "clones"},
{"id": "10003", "name": "Duplicate", "inward": "is duplicated by", "outward": "duplicates"},
{"id": "10004", "name": "Relates", "inward": "relates to", "outward": "relates to"},
],
"defaultStatusValue": "To Do",
"statuses": {
"10001": {
"id": "10001",
"name": "To Do",
"description": "Issue is open and not yet started",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
},
"10002": {
"id": "10002",
"name": "In Progress",
"description": "Issue is actively being worked on",
"statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress", "colorName": "yellow"},
},
"10003": {
"id": "10003",
"name": "In Review",
"description": "Issue is being reviewed",
"statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress", "colorName": "yellow"},
},
"10004": {
"id": "10004",
"name": "Done",
"description": "Issue is complete",
"statusCategory": {"id": 3, "key": "done", "name": "Done", "colorName": "green"},
},
},
"workflow": {
"To Do": [{"id": "1", "name": "Start Progress", "to": "In Progress"}],
"In Progress": [
{"id": "2", "name": "Submit for Review", "to": "In Review"},
{"id": "3", "name": "Mark Done", "to": "Done"},
{"id": "4", "name": "Back to To Do", "to": "To Do"},
],
"In Review": [
{"id": "5", "name": "Approve", "to": "Done"},
{"id": "6", "name": "Request Changes", "to": "In Progress"},
],
"Done": [{"id": "7", "name": "Reopen", "to": "To Do"}],
},
"counters": {
"issueId": 10000,
"sprintId": 1000,
"boardId": 1001,
"commentId": 0,
"worklogId": 0,
"attachmentId": 0,
"issueLinkId": 0,
},
}
def get_default_state() -> JiraState:
return JiraState.model_validate(deepcopy(_default_state_data()))
def get_default_sites() -> dict[str, JiraState]:
return {"default": get_default_state()}
def _canonicalize_state(state: JiraState) -> JiraState:
return JiraState.model_validate(state.model_dump(mode="json", by_alias=True, exclude_none=True))
def state_to_json() -> dict[str, Any]:
_ensure_loaded()
return _storage_from_sites(_sites)
def _max_attachment_id(state: JiraState) -> int:
max_id = state.counters.attachmentId
for issue in state.issues.values():
for attachment in issue.fields.attachment or []:
data = attachment.model_dump(mode="json", by_alias=True) if not isinstance(attachment, dict) else attachment
try:
value = data.get("id")
if value is not None:
max_id = max(max_id, int(value))
except (TypeError, ValueError):
continue
return max_id
def _max_issue_id(state: JiraState) -> int:
max_id = state.counters.issueId
for issue in state.issues.values():
try:
max_id = max(max_id, int(issue.id))
except (TypeError, ValueError):
continue
return max_id
def _max_sprint_id(state: JiraState) -> int:
max_id = state.counters.sprintId
for key, sprint in state.sprints.items():
max_id = max(max_id, sprint.id)
try:
max_id = max(max_id, int(key))
except (TypeError, ValueError):
continue
return max_id
def _max_board_id(state: JiraState) -> int:
max_id = state.counters.boardId
for key, board in state.boards.items():
max_id = max(max_id, board.id)
try:
max_id = max(max_id, int(key))
except (TypeError, ValueError):
continue
return max_id
def _max_comment_id(state: JiraState) -> int:
max_id = state.counters.commentId
for comments in state.comments.values():
for comment in comments:
try:
max_id = max(max_id, int(comment.id))
except (TypeError, ValueError):
continue
return max_id
def _max_worklog_id(state: JiraState) -> int:
max_id = state.counters.worklogId
for worklogs in state.worklogs.values():
for worklog in worklogs:
try:
max_id = max(max_id, int(worklog.id))
except (TypeError, ValueError):
continue
return max_id
def _max_issue_link_id(state: JiraState) -> int:
max_id = state.counters.issueLinkId
for issue in state.issues.values():
for link in issue.fields.issuelinks or []:
try:
max_id = max(max_id, int(link.id))
except (TypeError, ValueError):
continue
return max_id
def _max_project_id(state: JiraState) -> int:
max_id = 10000
for project in state.projects.values():
try:
max_id = max(max_id, int(project.id))
except (TypeError, ValueError):
continue
return max_id
def _next_project_id(state: JiraState) -> str:
return str(_max_project_id(state) + 1)
def _max_issue_key_suffix(state: JiraState, project_key: str) -> int:
pattern = re.compile(rf"^{re.escape(project_key)}-(\d+)$")
max_suffix = 0
for key in state.issues:
if match := pattern.fullmatch(key):
max_suffix = max(max_suffix, int(match.group(1)))
return max_suffix
def _collect_embedded_users(data: dict[str, Any]) -> dict[str, Any]:
users: dict[str, Any] = {}
def add_user(value: Any) -> None:
if isinstance(value, dict) and isinstance(value.get("accountId"), str):
users.setdefault(value["accountId"], value)
for issue in (data.get("issues") or {}).values():
if not isinstance(issue, dict):
continue
fields = issue.get("fields") or {}
if not isinstance(fields, dict):
continue
for field in ("assignee", "reporter", "creator"):
add_user(fields.get(field))
for component in fields.get("components") or []:
if isinstance(component, dict):
add_user(component.get("lead"))
for attachment in fields.get("attachment") or []:
if isinstance(attachment, dict):
add_user(attachment.get("author"))
watches = fields.get("watches") or {}
if isinstance(watches, dict):
for watcher in watches.get("watchers") or []:
add_user(watcher)
changelog = issue.get("changelog") or {}
if isinstance(changelog, dict):
for history in changelog.get("histories") or []:
if isinstance(history, dict):
add_user(history.get("author"))
for comments in (data.get("comments") or {}).values():
for comment in comments or []:
if isinstance(comment, dict):
add_user(comment.get("author"))
add_user(comment.get("updateAuthor"))
for worklogs in (data.get("worklogs") or {}).values():
for worklog in worklogs or []:
if isinstance(worklog, dict):
add_user(worklog.get("author"))
add_user(worklog.get("updateAuthor"))
return users
def _state_from_flat_json(data: dict[str, Any] | JiraState | None) -> JiraState:
defaults = get_default_state().model_dump(mode="json", by_alias=True)
incoming = data.model_dump(mode="json", by_alias=True) if isinstance(data, JiraState) else (data or {})
embedded_users = _collect_embedded_users(incoming)
explicit_users = "users" in incoming
users = (
{**embedded_users, **incoming.get("users", {})} if explicit_users else {**defaults["users"], **embedded_users}
)
current_user_account_id = incoming.get("currentUserAccountId")
if current_user_account_id is None:
current_user_account_id = next(iter(users), None) if explicit_users else defaults["currentUserAccountId"]
if current_user_account_id is None:
raise ValueError("Jira state requires at least one user or a currentUserAccountId")
merged = {
"is_admin": incoming.get("is_admin", defaults["is_admin"]),
"currentUserAccountId": current_user_account_id,
"users": users,
"issues": incoming.get("issues", defaults["issues"]),
"sprints": incoming.get("sprints", defaults["sprints"]),
"comments": incoming.get("comments", defaults["comments"]),
"worklogs": incoming.get("worklogs", defaults["worklogs"]),
"projects": incoming.get("projects", defaults["projects"]),
"boards": incoming.get("boards", defaults["boards"]),
"fields": incoming.get("fields", defaults["fields"]),
"linkTypes": incoming.get("linkTypes", defaults["linkTypes"]),
"defaultStatusValue": incoming.get("defaultStatusValue") or defaults["defaultStatusValue"],
"statuses": incoming.get("statuses") or defaults["statuses"],
"workflow": incoming.get("workflow") or defaults["workflow"],
"counters": {**defaults["counters"], **incoming.get("counters", {})},
}
state = JiraState.model_validate(merged)
state.counters.issueId = _max_issue_id(state)
state.counters.sprintId = _max_sprint_id(state)
state.counters.boardId = _max_board_id(state)
state.counters.commentId = _max_comment_id(state)
state.counters.worklogId = _max_worklog_id(state)
state.counters.attachmentId = _max_attachment_id(state)
state.counters.issueLinkId = _max_issue_link_id(state)
return _canonicalize_state(state)
def _sites_from_storage(data: dict[str, Any] | JiraMockState | None) -> dict[str, JiraState]:
if isinstance(data, JiraSitesState):
raw_sites = data.model_dump(mode="json", by_alias=True, exclude_unset=True).get("sites", {})
elif isinstance(data, JiraState):
raw_sites = {"default": data.model_dump(mode="json", by_alias=True, exclude_unset=True)}
else:
raw_data = data or {}
raw_sites = raw_data.get("sites", {"default": raw_data})
sites: dict[str, JiraState] = {}
for site_id, site_state in raw_sites.items():
sites[site_id] = _state_from_flat_json(site_state)
if not sites:
raise ValueError("Jira state must contain at least one site")
return sites
def _storage_from_sites(sites: dict[str, JiraState]) -> dict[str, Any]:
if set(sites) == {"default"}:
return sites["default"].model_dump(mode="json", by_alias=True, exclude_none=True)
return {
"sites": {
site_id: site.model_dump(mode="json", by_alias=True, exclude_none=True) for site_id, site in sites.items()
}
}
def _install_sites(sites: dict[str, JiraState]) -> None:
global _active_site_id, _current_state
_sites.clear()
_sites.update(sites)
_active_site_id = "default" if "default" in _sites else next(iter(_sites))
_current_state = _sites[_active_site_id]
def _ensure_loaded() -> None:
if _current_state is None:
load_state()
def state_from_json(data: dict[str, Any] | JiraMockState | None) -> None:
sites = _sites_from_storage(data)
_install_sites(sites)
save_state()
def dump_state(dest: Path, label: str) -> None:
if _current_state is None and not _sites:
return
try:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(json.dumps(state_to_json(), indent=2), encoding="utf-8")
_logger.info("Wrote Jira %s state to %s", label, dest)
except Exception:
_logger.exception("Failed to write Jira %s state to %s", label, dest)
def load_state() -> JiraState:
if _current_state is not None:
return _current_state
seed: dict[str, Any] | None = None
bundle_paths = resolve_bundle_state_paths()
if bundle_paths:
seed = _coalesce_site_files(bundle_paths)
_logger.info("Loaded state from bundle: %s", [str(p) for p in bundle_paths])
if seed is None and (input_dir := os.environ.get("INPUTDIR")):
json_files = sorted(Path(input_dir).glob("*.json"))
if json_files:
seed = json.loads(json_files[0].read_text(encoding="utf-8"))
_logger.info("Loaded state from INPUTDIR: %s", json_files[0])
if seed is None and _state_file is not None and _state_file.exists():
seed = json.loads(_state_file.read_text(encoding="utf-8"))
_logger.info("Loaded state from %s", _state_file)
state_from_json(seed or _default_state_data())
return get_state()
def init_state() -> None:
load_state()
configure_snapshots_from_env()
if _bundle_state_path is not None:
dump_state(_bundle_state_path, "bundle")
if output_dir := os.environ.get("OUTPUTDIR"):
dump_state(Path(output_dir) / "initial.json", "initial")
def save_state() -> None:
if _current_state is None:
return
_sites[_active_site_id] = _current_state
_validate_sites()
payload = _storage_from_sites(_sites)
if _state_file is None:
return
_state_file.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def _validate_sites() -> None:
if not _sites:
return
JiraSitesState.model_validate(
{
"sites": {
site_id: site.model_dump(mode="json", by_alias=True, exclude_none=True)
for site_id, site in _sites.items()
}
}
)
def get_state() -> JiraState:
if _current_state is None:
return load_state()
return _current_state
def reset_state() -> None:
_install_sites(get_default_sites())
save_state()
def get_active_site_id() -> str:
_ensure_loaded()
return _active_site_id
def set_active_site(site_id: str) -> None:
global _active_site_id, _current_state
_ensure_loaded()
if site_id not in _sites:
raise ValueError(f"Unknown Jira site {site_id!r}")
_active_site_id = site_id
_current_state = _sites[site_id]
def list_sites() -> dict[str, Any]:
_ensure_loaded()
sites = []
for site_id, site in _sites.items():
sites.append(
{
"site_id": site_id,
"is_active": site_id == _active_site_id,
"project_count": len(site.projects),
"issue_count": len(site.issues),
"user_count": len(site.users),
"board_count": len(site.boards),
"current_user_account_id": site.currentUserAccountId,
"is_admin": site.is_admin,
}
)
return {"status": "success", "sites": sites, "total": len(sites)}
def _next_counter(name: str) -> int:
state = get_state()
value = getattr(state.counters, name) + 1
setattr(state.counters, name, value)
save_state()
return value
def get_next_issue_id() -> int:
return _next_counter("issueId")
def get_next_sprint_id() -> int:
return _next_counter("sprintId")
def get_next_board_id() -> int:
return _next_counter("boardId")
def get_next_comment_id() -> int:
return _next_counter("commentId")
def get_next_worklog_id() -> int:
return _next_counter("worklogId")
def get_next_attachment_id() -> int:
return _next_counter("attachmentId")
def get_next_issue_link_id() -> int:
return _next_counter("issueLinkId")
def get_or_create_project(project_key: str) -> JiraProject:
state = get_state()
if project_key not in state.projects:
state.projects[project_key] = JiraProject.model_validate(
{
"id": _next_project_id(state),
"key": project_key,
"name": f"{project_key} Project",
"description": f"Auto-created project for {project_key}",
"projectTypeKey": "software",
"simplified": False,
}
)
save_state()
return state.projects[project_key]
def generate_issue_key(project_key: str) -> str:
return f"{project_key}-{_max_issue_key_suffix(get_state(), project_key) + 1}"
def is_admin_mode() -> bool:
return get_state().is_admin is True
def get_status_by_name(name: str) -> JiraStatus | None:
return next((status for status in get_state().statuses.values() if status.name.lower() == name.lower()), None)
def get_transitions_for_issue(issue: JiraIssue) -> list[dict[str, Any]]:
state = get_state()
return [
{
"id": transition.id,
"name": transition.name,
"to": (get_status_by_name(transition.to) or JiraStatus(id="0", name=transition.to)).model_dump(
mode="json", by_alias=True
),
"hasScreen": False,
"isGlobal": False,
"isInitial": False,
"isAvailable": True,
"isConditional": False,
"isLooped": False,
}
for transition in state.workflow.get(issue.fields.status.name, [])
]
@@ -0,0 +1 @@
"""Jira tool implementation modules."""
@@ -0,0 +1,120 @@
"""Collaboration tool handlers for comments, watchers, and attachments."""
from __future__ import annotations
import base64
import mimetypes
from typing import Any
from jira_mock.models import AccountId, Base64String, JiraAttachment, JiraComment, JiraWatches
from jira_mock.state import get_next_attachment_id, get_next_comment_id, get_state, save_state
from jira_mock.tools.common import IssueKey, _adf, _current_user, _dump, _now, _require_user, ensure_watches
def add_comment(issue_key: IssueKey, comment: str) -> dict[str, Any]:
"""Add a comment to a Jira issue."""
state = get_state()
if issue_key not in state.issues:
raise ValueError(f"Issue {issue_key} not found")
now = _now()
data = {
"id": str(get_next_comment_id()),
"author": _dump(_current_user()),
"body": _adf(comment),
"created": now,
"updated": now,
}
jira_comment = JiraComment.model_validate(data)
state.comments.setdefault(issue_key, []).append(jira_comment)
save_state()
return _dump(jira_comment)
def add_watcher(issue_key: IssueKey, account_id: AccountId) -> dict[str, str]:
"""Add a watcher to a Jira issue."""
issue = get_state().issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
watcher = _require_user(account_id)
watches = ensure_watches(issue)
if any(watcher.accountId == account_id for watcher in watches.watchers):
return {"message": f"User {account_id} is already watching {issue_key}"}
watches.watchers.append(watcher)
watches.watchCount = len(watches.watchers)
issue.fields.updated = _now()
save_state()
return {"message": f"Added watcher {account_id} to {issue_key}"}
def remove_watcher(issue_key: IssueKey, account_id: AccountId) -> dict[str, str]:
"""Remove a watcher from a Jira issue."""
issue = get_state().issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
watches = ensure_watches(issue)
before = len(watches.watchers)
remaining_watchers = [watcher for watcher in watches.watchers if watcher.accountId != account_id]
if len(remaining_watchers) == before:
raise ValueError(f"User {account_id} is not watching {issue_key}")
issue.fields.watches = JiraWatches(
watchCount=len(remaining_watchers),
isWatching=watches.isWatching,
watchers=remaining_watchers,
)
issue.fields.updated = _now()
save_state()
return {"message": f"Removed watcher {account_id} from {issue_key}"}
def get_watchers(issue_key: IssueKey) -> dict[str, Any]:
"""Get all watchers of a Jira issue."""
issue = get_state().issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
watches = ensure_watches(issue)
return _dump(watches)
def add_attachment(
issue_key: IssueKey, filename: str, content_base64: Base64String, mime_type: str | None = None
) -> dict[str, Any]:
"""Attach a base64-encoded file to a Jira issue."""
issue = get_state().issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
try:
size = len(base64.b64decode(content_base64, validate=True))
except Exception as exc:
raise ValueError("content_base64 must be valid base64") from exc
mime = mime_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
now = _now()
attachment_id = str(get_next_attachment_id())
attachment = JiraAttachment.model_validate(
{
"id": attachment_id,
"filename": filename,
"author": _dump(_current_user()),
"created": now,
"size": size,
"mimeType": mime,
"content": content_base64,
}
)
issue.fields.attachment = issue.fields.attachment or []
issue.fields.attachment.append(attachment)
issue.fields.updated = now
save_state()
return _dump(attachment)
def get_attachments(issue_key: IssueKey) -> dict[str, Any]:
"""List all attachments on a Jira issue."""
issue = get_state().issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
attachments = []
for attachment in issue.fields.attachment or []:
data = _dump(attachment)
data.pop("content", None)
attachments.append(data)
return {"attachments": attachments, "total": len(attachments)}
@@ -0,0 +1,227 @@
"""Shared helpers for Jira tool handlers."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Annotated, Any
from pydantic import Field
from jira_mock.models import IssueKey as JiraIssueKey
from jira_mock.models import (
JiraComponent,
JiraIssue,
JiraIssueLinkType,
JiraLinkedIssue,
JiraSiteId,
JiraUser,
JiraWatches,
NumericIdString,
ProjectKey,
)
from jira_mock.state import (
generate_issue_key,
get_next_issue_id,
get_or_create_project,
get_state,
get_status_by_name,
is_admin_mode,
save_state,
)
DEFAULT_READ_JIRA_FIELDS = "summary,status,assignee,issuetype,priority,created,updated,description,labels"
IssueKey = Annotated[JiraIssueKey, Field(description="Jira issue key, for example MOCK-1")]
ProjectKeyArg = Annotated[ProjectKey, Field(description="Jira project key, for example MOCK")]
BoardIdArg = Annotated[NumericIdString, Field(description="Jira board ID, for example 1000")]
LimitArg = Annotated[int, Field(default=10, ge=0, le=50, description="Maximum number of results")]
SprintIdArg = Annotated[NumericIdString, Field(description="Jira sprint ID, for example 1001")]
StartAtArg = Annotated[int, Field(default=0, ge=0, description="Starting index for pagination")]
TransitionIdArg = Annotated[NumericIdString, Field(description="Jira workflow transition ID")]
SiteIdArg = Annotated[JiraSiteId, Field(description="Jira site ID. Defaults to the default site.")]
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _dump(value: Any) -> Any:
if hasattr(value, "model_dump"):
return value.model_dump(mode="json", by_alias=True, exclude_none=True)
if isinstance(value, list):
return [_dump(item) for item in value]
if isinstance(value, dict):
return {key: _dump(item) for key, item in value.items() if item is not None}
return value
def _require_user(account_id: str) -> JiraUser:
user = get_state().users.get(account_id)
if user is None:
raise ValueError(f"User {account_id} not found")
return user
def _current_user() -> JiraUser:
account_id = get_state().currentUserAccountId
if account_id is None:
raise ValueError("No current Jira user is configured")
return _require_user(account_id)
def _resolve_user_ref(value: str | dict[str, Any] | JiraUser) -> JiraUser:
if isinstance(value, str):
return _require_user(value)
user = JiraUser.model_validate(value)
return _require_user(user.accountId)
def _linked_issue(issue: JiraIssue) -> JiraLinkedIssue:
return JiraLinkedIssue.model_validate(
{
"id": issue.id,
"key": issue.key,
"fields": {
"summary": issue.fields.summary,
"status": _dump(issue.fields.status),
"issuetype": _dump(issue.fields.issuetype),
},
}
)
def _resolve_link_type(link_type: str) -> tuple[JiraIssueLinkType, bool]:
lowered = link_type.lower()
for candidate in get_state().linkTypes:
if lowered in {candidate.name.lower(), candidate.outward.lower()}:
return candidate, False
if lowered == candidate.inward.lower():
return candidate, True
valid_types = sorted({value for item in get_state().linkTypes for value in (item.name, item.inward, item.outward)})
raise ValueError(f"Unknown link type: {link_type}. Valid types: {', '.join(valid_types)}")
def _parse_components(components: str) -> list[JiraComponent]:
names = [name.strip() for name in components.split(",") if name.strip()]
return [JiraComponent(name=name) for name in names]
def _adf(text: str) -> dict[str, Any]:
return {
"type": "doc",
"version": 1,
"content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}],
}
def _adf_to_text(node: Any) -> str:
if node is None:
return ""
if isinstance(node, str):
return node
if hasattr(node, "model_dump"):
node = node.model_dump(mode="json", by_alias=True)
if not isinstance(node, dict):
return ""
if isinstance(node.get("text"), str):
return node["text"]
if isinstance(node.get("content"), list):
return " ".join(_adf_to_text(item) for item in node["content"])
return ""
def _field_dict(issue: JiraIssue) -> dict[str, Any]:
return issue.fields.model_dump(mode="json", by_alias=True, exclude_none=True)
def _requested_fields(fields: str | None) -> set[str] | None:
if fields is None:
return None
requested = {field.strip() for field in fields.split(",") if field.strip()}
if not requested or "*all" in requested:
return None
return requested
def _dump_issue(
issue: JiraIssue, fields: str | None = None, extra_fields: dict[str, Any] | None = None
) -> dict[str, Any]:
response = _dump(issue)
requested = _requested_fields(fields)
response_fields = response.setdefault("fields", {})
if extra_fields:
response_fields.update(extra_fields)
if requested is not None:
response["fields"] = {key: value for key, value in response_fields.items() if key in requested}
return response
def _dump_issues(issues: list[JiraIssue], fields: str | None = None) -> list[dict[str, Any]]:
return [_dump_issue(issue, fields) for issue in issues]
def _field(issue: JiraIssue, key: str, default: Any = None) -> Any:
if hasattr(issue.fields, key):
return getattr(issue.fields, key)
return (issue.fields.model_extra or {}).get(key, default)
def _set_field(issue: JiraIssue, key: str, value: Any) -> None:
setattr(issue.fields, key, value)
def require_admin() -> None:
if not is_admin_mode():
raise PermissionError("Admin privileges are required to configure Jira statuses, workflows, or boards")
def create_new_issue(
project_key: ProjectKey, summary: str, issue_type: str, description: str | None = None, assignee: str | None = None
) -> JiraIssue:
state = get_state()
project = get_or_create_project(project_key)
issue_key = generate_issue_key(project_key)
issue_id = get_next_issue_id()
now = _now()
default_status = get_status_by_name(state.defaultStatusValue)
if default_status is None:
raise ValueError(f"Default status {state.defaultStatusValue!r} is not configured")
if default_status.name not in state.workflow:
raise ValueError(f"Default status {default_status.name!r} does not have a workflow entry")
issue = JiraIssue.model_validate(
{
"id": str(issue_id),
"key": issue_key,
"fields": {
"summary": summary,
"description": _adf(description) if description else None,
"issuetype": {
"id": "10001",
"name": issue_type,
"description": f"A {issue_type.lower()}",
"subtask": issue_type.lower() == "subtask",
"hierarchyLevel": 1 if issue_type.lower() == "epic" else 0,
},
"project": project.model_dump(mode="json", by_alias=True),
"status": default_status.model_dump(mode="json", by_alias=True),
"priority": {"id": "3", "name": "Medium"},
"assignee": _dump(_require_user(assignee)) if assignee else None,
"reporter": _dump(_current_user()),
"creator": _dump(_current_user()),
"created": now,
"updated": now,
"labels": [],
"components": [],
"fixVersions": [],
"versions": [],
},
}
)
state.issues[issue_key] = issue
save_state()
return issue
def ensure_watches(issue: JiraIssue) -> JiraWatches:
issue.fields.watches = issue.fields.watches or JiraWatches()
return issue.fields.watches
@@ -0,0 +1,837 @@
"""Issue and JQL tool handlers."""
from __future__ import annotations
import json
import re
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Annotated, Any
from pydantic import Field
from jira_mock.models import (
IssueTypeName,
JiraCommentPage,
JiraDocumentContent,
JiraIssue,
JiraIssueLink,
JiraPriority,
JiraWorklogPage,
)
from jira_mock.state import get_next_issue_link_id, get_state, save_state
from jira_mock.tools.common import (
DEFAULT_READ_JIRA_FIELDS,
IssueKey,
LimitArg,
ProjectKeyArg,
StartAtArg,
_adf,
_adf_to_text,
_dump,
_dump_issue,
_dump_issues,
_field,
_field_dict,
_linked_issue,
_now,
_parse_components,
_requested_fields,
_resolve_link_type,
_resolve_user_ref,
create_new_issue,
)
def _user_matches(user: Any, filters: list[str]) -> bool:
if user is None:
return False
data = _dump(user)
if not isinstance(data, dict):
return False
account_id = str(data.get("accountId", "")).lower()
display_name = str(data.get("displayName", "")).lower()
email = str(data.get("emailAddress", "")).lower()
current_account_id = get_state().currentUserAccountId
normalized_filters = [
current_account_id.lower() if value.lower() == "currentuser()" and current_account_id else value
for value in filters
if value.lower() != "currentuser()" or current_account_id
]
return any(account_id == value or value in display_name or value in email for value in normalized_filters)
def _normalize_jql_value(value: str) -> str:
return value.strip().strip("'\"").strip()
def _parse_list_values(raw: str) -> list[str]:
values: list[str] = []
for match in re.finditer(r'"([^"]+)"|\'([^\']+)\'|([^,\s][^,]*)', raw):
value = _normalize_jql_value(next(group for group in match.groups() if group is not None))
if value:
values.append(value)
return values
def _parse_query_tokens(query: str) -> list[str]:
return [
(match.group(1) or match.group(0)).strip().lower()
for match in re.finditer(r'"([^"]+)"|\S+', query)
if (match.group(1) or match.group(0)).strip()
]
def _all_tokens_match(query: str, haystack: str) -> bool:
tokens = _parse_query_tokens(query)
lowered = haystack.lower()
return bool(tokens) and all(token in lowered for token in tokens)
def _extract_contains(jql: str, field: str) -> str | None:
match = re.search(rf"\b{field}\s*~\s*(?:\"([^\"]+)\"|'([^']+)'|(\S+))", jql, re.IGNORECASE)
if not match:
return None
return _normalize_jql_value(next(group for group in match.groups() if group is not None))
def _find_top_level_order_by_index(jql: str) -> int | None:
depth = 0
quote: str | None = None
for i, char in enumerate(jql):
if quote:
if char == quote:
quote = None
continue
if char in {"'", '"'}:
quote = char
continue
if char == "(":
depth += 1
continue
if char == ")":
depth = max(0, depth - 1)
continue
if (
depth == 0
and re.match(r"order\s+by\b", jql[i:], re.IGNORECASE)
and (i == 0 or not re.match(r"[A-Za-z0-9_]", jql[i - 1]))
):
return i
return None
def _strip_order_by(jql: str) -> str:
index = _find_top_level_order_by_index(jql)
return (jql if index is None else jql[:index]).strip()
def _parse_order_by(jql: str) -> tuple[str, str] | None:
index = _find_top_level_order_by_index(jql)
if index is None:
return None
match = re.match(r"order\s+by\s+(\w+)(?:\s+(asc|desc))?", jql[index:], re.IGNORECASE)
if not match:
return None
return match.group(1).lower(), (match.group(2) or "asc").lower()
def _split_top_level_or_clauses(jql: str) -> list[str]:
source = _strip_order_by(jql)
clauses: list[str] = []
start = 0
depth = 0
quote: str | None = None
i = 0
while i < len(source):
char = source[i]
if quote:
if char == quote:
quote = None
i += 1
continue
if char in {"'", '"'}:
quote = char
i += 1
continue
if char == "(":
depth += 1
i += 1
continue
if char == ")":
depth = max(0, depth - 1)
i += 1
continue
if (
depth == 0
and source[i : i + 2].lower() == "or"
and (i == 0 or not re.match(r"[A-Za-z0-9_]", source[i - 1]))
and (i + 2 >= len(source) or not re.match(r"[A-Za-z0-9_]", source[i + 2]))
):
if clause := source[start:i].strip():
clauses.append(clause)
start = i + 2
i += 2
continue
i += 1
if tail := source[start:].strip():
clauses.append(tail)
return clauses or [source]
def _has_parenthesized_or(jql: str) -> bool:
source = _strip_order_by(jql)
depth = 0
quote: str | None = None
for i, char in enumerate(source):
if quote:
if char == quote:
quote = None
continue
if char in {"'", '"'}:
quote = char
continue
if char == "(":
depth += 1
continue
if char == ")":
depth = max(0, depth - 1)
continue
if (
depth > 0
and source[i : i + 2].lower() == "or"
and (i == 0 or not re.match(r"[A-Za-z0-9_]", source[i - 1]))
and (i + 2 >= len(source) or not re.match(r"[A-Za-z0-9_]", source[i + 2]))
):
return True
return False
FIELD_ALIASES: dict[str, list[str]] = {
"key": ["key", "issuekey"],
"project": ["project"],
"status": ["status"],
"assignee": ["assignee"],
"reporter": ["reporter"],
"priority": ["priority"],
"issueType": ["issuetype", "type"],
"labels": ["labels", "label"],
"components": ["components", "component"],
"sprint": ["sprint", "customfield_10002"],
"fixVersion": ["fixVersions", "fixVersion"],
"statusCategory": ["statusCategory"],
"resolution": ["resolution"],
"parent": ["parent"],
"due": ["due", "duedate"],
}
def _field_pattern(names: list[str]) -> str:
return "|".join(re.escape(name) for name in names)
def _get_field_condition(jql: str, names: list[str]) -> dict[str, Any] | None:
fields = _field_pattern(names)
if match := re.search(rf"\b(?:{fields})\b\s+is\s+(not\s+)?(?:empty|null)\b", jql, re.IGNORECASE):
return {"values": [], "negate": False, "empty": not match.group(1)}
if match := re.search(rf"\b(?:{fields})\b\s+(not\s+in|in)\s*\(([^)]*)\)", jql, re.IGNORECASE):
values = _parse_list_values(match.group(2))
return {"values": values, "negate": match.group(1).lower().startswith("not"), "empty": None}
if match := re.search(rf"\b(?:{fields})\b\s*(!=|=)\s*(?:\"([^\"]+)\"|'([^']+)'|(\S+))", jql, re.IGNORECASE):
value = _normalize_jql_value(next(group for group in match.groups()[1:] if group is not None))
return {
"values": [value] if value and value.lower() not in {"empty", "null"} else [],
"negate": match.group(1) == "!=",
"empty": True if value.lower() in {"empty", "null"} else None,
}
return None
def _apply_condition(
issues: list[JiraIssue],
condition: dict[str, Any] | None,
value_matches: Callable[[JiraIssue, list[str]], bool],
is_empty: Callable[[JiraIssue], bool],
) -> list[JiraIssue]:
if not condition:
return issues
if condition["empty"] is not None:
return [issue for issue in issues if (is_empty(issue) == condition["empty"]) != condition["negate"]]
filters = [value.lower() for value in condition["values"]]
return [issue for issue in issues if value_matches(issue, filters) != condition["negate"]]
def _parse_date_bound(value: str) -> int | None:
normalized = _normalize_jql_value(value)
if not normalized:
return None
normalized = normalized.replace("Z", "+00:00")
try:
return int(datetime.fromisoformat(normalized).timestamp() * 1000)
except ValueError:
try:
return int(datetime.strptime(normalized, "%Y-%m-%d").replace(tzinfo=UTC).timestamp() * 1000)
except ValueError:
return None
def _apply_date_filters(
issues: list[JiraIssue], jql: str, names: list[str], get_value: Callable[[JiraIssue], str | None]
) -> list[JiraIssue]:
filtered = issues
regex = re.compile(
rf"\b(?:{_field_pattern(names)})\b\s*(<=|>=|<|>|=)\s*(?:\"([^\"]+)\"|'([^']+)'|(\S+))", re.IGNORECASE
)
for match in regex.finditer(jql):
op = match.group(1)
raw = next(group for group in match.groups()[1:] if group is not None)
bound = _parse_date_bound(raw)
if bound is None:
return []
if op == "=" and re.fullmatch(r"\d{4}-\d{2}-\d{2}", _normalize_jql_value(raw)):
end = bound + 24 * 3600 * 1000
filtered = [
issue
for issue in filtered
if (time_value := _parse_date_bound(get_value(issue) or "")) is not None and bound <= time_value < end
]
else:
filtered = [
issue
for issue in filtered
if (time_value := _parse_date_bound(get_value(issue) or "")) is not None
and (
(op == "<" and time_value < bound)
or (op == "<=" and time_value <= bound)
or (op == ">" and time_value > bound)
or (op == ">=" and time_value >= bound)
or (op == "=" and time_value == bound)
)
]
return filtered
def _collect_sprint_field_names() -> list[str]:
configured: list[str] = []
for field in get_state().fields:
if field.name.lower() == "sprint" or field.key.lower() == "sprint":
configured.extend([field.key, field.id])
return list(dict.fromkeys([*FIELD_ALIASES["sprint"], *configured]))
def _apply_jql_filters(initial_issues: list[JiraIssue], jql: str) -> list[JiraIssue]:
issues = initial_issues
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["key"]),
lambda i, f: i.key.lower() in f,
lambda _i: False,
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["project"]),
lambda i, f: i.fields.project.key.lower() in f,
lambda _i: False,
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["status"]),
lambda i, f: i.fields.status.name.lower() in f,
lambda _i: False,
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["assignee"]),
lambda i, f: _user_matches(i.fields.assignee, f),
lambda i: i.fields.assignee is None,
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["reporter"]),
lambda i, f: _user_matches(i.fields.reporter, f),
lambda i: i.fields.reporter is None,
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["priority"]),
lambda i, f: (
str(_dump(i.fields.priority).get("name", "") if isinstance(_dump(i.fields.priority), dict) else "").lower()
in f
),
lambda i: i.fields.priority is None,
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["issueType"]),
lambda i, f: i.fields.issuetype.name.lower() in f,
lambda _i: False,
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["labels"]),
lambda i, f: any(label.lower() in f for label in i.fields.labels or []),
lambda i: not i.fields.labels,
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["components"]),
lambda i, f: any(str(_dump(component).get("name", "")).lower() in f for component in i.fields.components or []),
lambda i: not i.fields.components,
)
sprint_fields = _collect_sprint_field_names()
issues = _apply_condition(
issues,
_get_field_condition(jql, sprint_fields),
lambda i, f: any((value := _field(i, name)) is not None and str(value).lower() in f for name in sprint_fields),
lambda i: all(_field(i, name) is None for name in sprint_fields),
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["fixVersion"]),
lambda i, f: any(str(_dump(version).get("name", "")).lower() in f for version in i.fields.fixVersions or []),
lambda i: not i.fields.fixVersions,
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["statusCategory"]),
lambda i, f: any(
str(value).lower() in f
for value in [
i.fields.status.statusCategory.name if i.fields.status.statusCategory else None,
i.fields.status.statusCategory.key if i.fields.status.statusCategory else None,
i.fields.status.statusCategory.id if i.fields.status.statusCategory else None,
]
if value is not None
),
lambda i: i.fields.status.statusCategory is None,
)
issues = _apply_condition(
issues,
_get_field_condition(jql, FIELD_ALIASES["parent"]),
lambda i, f: any(
str(value).lower() in f
for value in [_field_dict(i).get("parent", {}).get("key"), _field_dict(i).get("parent", {}).get("id")]
if value is not None
),
lambda i: _field(i, "parent") is None,
)
issues = _apply_date_filters(issues, jql, ["created"], lambda i: i.fields.created)
issues = _apply_date_filters(issues, jql, ["updated"], lambda i: i.fields.updated)
issues = _apply_date_filters(issues, jql, FIELD_ALIASES["due"], lambda i: _field(i, "duedate") or _field(i, "due"))
if summary := _extract_contains(jql, "summary"):
issues = [issue for issue in issues if _all_tokens_match(summary, issue.fields.summary or "")]
if description := _extract_contains(jql, "description"):
issues = [issue for issue in issues if _all_tokens_match(description, _adf_to_text(issue.fields.description))]
if text := _extract_contains(jql, "text"):
issues = [
issue
for issue in issues
if _all_tokens_match(text, f"{issue.fields.summary or ''}\n{_adf_to_text(issue.fields.description)}")
]
return issues
def _apply_ordering(issues: list[JiraIssue], jql: str) -> list[JiraIssue]:
order_by = _parse_order_by(jql)
if not order_by:
return issues
field, direction = order_by
reverse = direction == "desc"
def issue_key_sort_key(issue_key: str) -> tuple[str, int]:
project_key, _, number = issue_key.partition("-")
try:
return (project_key, int(number))
except ValueError:
return (project_key, 0)
def key(issue: JiraIssue) -> Any:
if field in {"created", "updated"}:
return _parse_date_bound(getattr(issue.fields, field)) or 0
if field == "priority":
data = _dump(issue.fields.priority)
return data.get("name", "") if isinstance(data, dict) else ""
if field == "status":
return issue.fields.status.name
if field in {"issuetype", "type"}:
return issue.fields.issuetype.name
if field == "key":
return issue_key_sort_key(issue.key)
return issue.fields.summary or ""
return sorted(issues, key=key, reverse=reverse)
def _collect_jql_warnings(jql: str) -> list[str]:
warnings: list[str] = []
if re.search(r"\bcurrentUser\(\)", jql, re.IGNORECASE) and get_state().currentUserAccountId is None:
warnings.append("currentUser() was not applied because no current Jira user is configured.")
known_fields = {
*[field.lower() for fields in FIELD_ALIASES.values() for field in fields],
"created",
"updated",
"summary",
"description",
"text",
}
recognized = False
condition_regex = re.compile(
r"\b([A-Za-z][A-Za-z0-9_]*)\b\s*(not\s+in|in|is\s+not|is|!=|=|~|<=|>=|<|>|was|changed)", re.IGNORECASE
)
for match in condition_regex.finditer(jql):
field = match.group(1)
operator = match.group(2).lower()
if field.lower() == "by":
continue
if field.lower() not in known_fields:
message = f"Unsupported JQL field '{field}' was not applied by this search."
if message not in warnings:
warnings.append(message)
continue
recognized = True
if operator in {"was", "changed"}:
warnings.append(
f"Unsupported JQL operator '{operator}' for field '{field}' was not applied by this search."
)
if _has_parenthesized_or(jql):
warnings.append("Parenthesized OR clauses are not supported by this search.")
if jql.strip() and not recognized and not re.search(r"\border\s+by\b", jql, re.IGNORECASE):
warnings.append("No supported JQL clauses were recognized; results may be unfiltered.")
return list(dict.fromkeys(warnings))
def _normalize_search_limit(limit: int | None, warnings: list[str]) -> int:
if limit is None:
return 10
if limit < 0:
warnings.append("limit must be non-negative; using 0.")
return 0
if limit > 50:
warnings.append("limit exceeds the maximum of 50; using 50.")
return 50
return limit
def _normalize_start_at(start_at: int | None, warnings: list[str]) -> int:
if start_at is None:
return 0
if start_at < 0:
warnings.append("startAt must be non-negative; using 0.")
return 0
return start_at
def search(
jql: Annotated[str, Field(description="JQL query string")],
fields: Annotated[str, Field(description="Comma-separated fields to return")] = DEFAULT_READ_JIRA_FIELDS,
limit: Annotated[int | None, Field(description="Maximum number of results (1-50)")] = 10,
startAt: Annotated[int | None, Field(description="Starting index for pagination")] = 0,
projects_filter: Annotated[str | None, Field(description="Comma-separated project keys to filter")] = None,
) -> dict[str, Any]:
"""Search Jira issues using JQL."""
state = get_state()
warnings = _collect_jql_warnings(jql)
normalized_limit = _normalize_search_limit(limit, warnings)
normalized_start = _normalize_start_at(startAt, warnings)
if _has_parenthesized_or(jql):
return {
"startAt": normalized_start,
"maxResults": normalized_limit,
"total": 0,
"issues": [],
"warningMessages": warnings,
}
all_issues = list(state.issues.values())
clauses = _split_top_level_or_clauses(jql)
if len(clauses) > 1:
keys = {issue.key for clause in clauses for issue in _apply_jql_filters(all_issues, clause)}
issues = [issue for issue in all_issues if issue.key in keys]
else:
issues = _apply_jql_filters(all_issues, jql)
if projects_filter:
project_keys = {project.strip().lower() for project in projects_filter.split(",")}
issues = [issue for issue in issues if issue.fields.project.key.lower() in project_keys]
issues = _apply_ordering(issues, jql)
total = len(issues)
page = issues[normalized_start : normalized_start + normalized_limit]
return {
"startAt": normalized_start,
"maxResults": normalized_limit,
"total": total,
"issues": _dump_issues(page, fields),
"warningMessages": warnings,
}
def get_issue(
issue_key: IssueKey,
fields: Annotated[str, Field(description="Fields to return")] = DEFAULT_READ_JIRA_FIELDS,
expand: Annotated[str | None, Field(description="Optional fields to expand")] = None,
comment_limit: Annotated[int, Field(description="Maximum number of comments")] = 10,
) -> dict[str, Any]:
"""Get details of a specific Jira issue."""
del expand
state = get_state()
issue = state.issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
requested = _requested_fields(fields)
extra_fields = {}
if (requested is None or "comment" in requested) and comment_limit and comment_limit > 0:
comments = state.comments.get(issue_key, [])
extra_fields["comment"] = _dump(
JiraCommentPage(
comments=comments[:comment_limit],
maxResults=comment_limit,
total=len(comments),
startAt=0,
)
)
if requested is None or "worklog" in requested:
worklogs = state.worklogs.get(issue_key, [])
extra_fields["worklog"] = _dump(
JiraWorklogPage(
worklogs=worklogs,
maxResults=len(worklogs),
total=len(worklogs),
startAt=0,
)
)
return _dump_issue(issue, fields, extra_fields)
def get_project_issues(project_key: ProjectKeyArg, limit: LimitArg = 10, startAt: StartAtArg = 0) -> dict[str, Any]:
"""Get all issues for a specific Jira project."""
issues = [issue for issue in get_state().issues.values() if issue.fields.project.key == project_key]
return {
"startAt": startAt,
"maxResults": limit,
"total": len(issues),
"issues": _dump(issues[startAt : startAt + limit]),
}
def get_epic_issues(epic_key: IssueKey, limit: LimitArg = 10, startAt: StartAtArg = 0) -> dict[str, Any]:
"""Get all issues linked to a specific epic."""
issues = [
issue
for issue in get_state().issues.values()
if (_field_dict(issue).get("parent") or {}).get("key") == epic_key
]
return {
"startAt": startAt,
"maxResults": limit,
"total": len(issues),
"issues": _dump(issues[startAt : startAt + limit]),
}
def create_issue(
project_key: ProjectKeyArg,
summary: str,
issue_type: IssueTypeName,
assignee: str | None = None,
description: str = "",
components: str = "",
additional_fields: str = "{}",
) -> dict[str, Any]:
"""Create a new Jira issue."""
parsed_components = _parse_components(components) if components.strip() else []
try:
additional = json.loads(additional_fields) if additional_fields else {}
except json.JSONDecodeError as exc:
raise ValueError("Invalid JSON in `additional_fields` parameter") from exc
if not isinstance(additional, dict):
raise ValueError("Invalid JSON in `additional_fields` parameter")
parent = None
if parent_key := additional.get("parent"):
parent = get_state().issues.get(parent_key)
labels = additional.get("labels")
priority = JiraPriority.model_validate(additional["priority"]) if "priority" in additional else None
issue = create_new_issue(project_key, summary, issue_type, description, assignee)
if parsed_components:
issue.fields.components = parsed_components
save_state()
if additional:
if parent:
issue.fields.parent = _linked_issue(parent)
if labels is not None:
issue.fields.labels = labels
if priority is not None:
issue.fields.priority = priority
save_state()
return {"id": issue.id, "key": issue.key}
def update_issue(
issue_key: IssueKey,
fields: Annotated[
str,
Field(
description=(
"JSON object string of editable issue fields. Supports summary, description, priority, assignee, "
'and labels. Example: {"summary":"New title","description":"Updated details","labels":["backend"]}. '
"Do not use this for status changes; use get_transitions and transition_issue instead."
)
),
],
) -> dict[str, Any]:
"""Update editable Jira issue fields. Use transition_issue for status/workflow changes."""
issue = get_state().issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
try:
fields_obj = json.loads(fields)
except json.JSONDecodeError as exc:
raise ValueError("Invalid JSON in `fields` parameter") from exc
if not isinstance(fields_obj, dict):
raise ValueError("Invalid JSON in `fields` parameter")
supported_fields = {"summary", "description", "status", "priority", "assignee", "labels"}
unsupported_fields = sorted(set(fields_obj) - supported_fields)
if unsupported_fields:
raise ValueError(f"Unsupported update_issue field(s): {', '.join(unsupported_fields)}")
if "status" in fields_obj:
raise ValueError(
"Cannot change status directly via update_issue. Use transition_issue with a valid workflow transition instead. Call get_transitions to see available transitions."
)
description_update = None
if "description" in fields_obj:
description = fields_obj["description"]
if description is not None and isinstance(description, str):
description_update = JiraDocumentContent.model_validate(_adf(description))
elif description is not None:
description_update = JiraDocumentContent.model_validate(description)
priority_update = None
if "priority" in fields_obj:
priority = fields_obj["priority"]
priority_update = (
None
if priority is None
else JiraPriority.model_validate(
{"id": issue.fields.priority.id if issue.fields.priority else "3", "name": priority}
)
if isinstance(priority, str)
else JiraPriority.model_validate(priority)
)
assignee_update = None
if "assignee" in fields_obj and fields_obj["assignee"] is not None:
assignee_update = _resolve_user_ref(fields_obj["assignee"])
draft = issue.model_copy(deep=True)
if "summary" in fields_obj:
draft.fields.summary = fields_obj["summary"]
if "description" in fields_obj:
draft.fields.description = description_update
if "priority" in fields_obj:
draft.fields.priority = priority_update
if "assignee" in fields_obj:
draft.fields.assignee = assignee_update
if "labels" in fields_obj:
draft.fields.labels = fields_obj["labels"]
draft.fields.updated = _now()
get_state().issues[issue_key] = JiraIssue.model_validate(draft.model_dump(mode="json", by_alias=True))
save_state()
return _dump(get_state().issues[issue_key])
def delete_issue(issue_key: IssueKey) -> dict[str, str]:
"""Delete an existing Jira issue."""
state = get_state()
if issue_key not in state.issues:
raise ValueError(f"Issue {issue_key} not found")
del state.issues[issue_key]
state.comments.pop(issue_key, None)
state.worklogs.pop(issue_key, None)
for issue in state.issues.values():
if issue.fields.parent is not None and issue.fields.parent.key == issue_key:
issue.fields.parent = None
if issue.fields.subtasks:
issue.fields.subtasks = [subtask for subtask in issue.fields.subtasks if subtask.key != issue_key]
if issue.fields.issuelinks:
issue.fields.issuelinks = [
link
for link in issue.fields.issuelinks
if (link.inwardIssue is None or link.inwardIssue.key != issue_key)
and (link.outwardIssue is None or link.outwardIssue.key != issue_key)
]
save_state()
return {"message": f"Issue {issue_key} deleted successfully"}
def link_issues(
inward_issue_key: IssueKey, outward_issue_key: IssueKey, link_type: str, comment: str | None = None
) -> dict[str, Any]:
"""Create a bidirectional link between two Jira issues."""
del comment
if inward_issue_key == outward_issue_key:
raise ValueError("Cannot link an issue to itself")
state = get_state()
inward_issue = state.issues.get(inward_issue_key)
outward_issue = state.issues.get(outward_issue_key)
if inward_issue is None:
raise ValueError(f"Inward issue {inward_issue_key} not found")
if outward_issue is None:
raise ValueError(f"Outward issue {outward_issue_key} not found")
found_type, should_reverse = _resolve_link_type(link_type)
if should_reverse:
inward_issue, outward_issue = outward_issue, inward_issue
inward_issue.fields.issuelinks = inward_issue.fields.issuelinks or []
outward_issue.fields.issuelinks = outward_issue.fields.issuelinks or []
existing_link = next(
(
link
for link in outward_issue.fields.issuelinks
if link.type.id == found_type.id
and link.outwardIssue is not None
and link.outwardIssue.key == inward_issue.key
),
None,
)
if existing_link is None:
existing_link = next(
(
link
for link in inward_issue.fields.issuelinks
if link.type.id == found_type.id
and link.inwardIssue is not None
and link.inwardIssue.key == outward_issue.key
),
None,
)
if existing_link is not None:
return {
"id": existing_link.id,
"type": _dump(found_type),
"inwardIssue": {"key": inward_issue.key, "summary": inward_issue.fields.summary},
"outwardIssue": {"key": outward_issue.key, "summary": outward_issue.fields.summary},
}
link_id = str(get_next_issue_link_id())
outward_issue.fields.issuelinks.append(
JiraIssueLink(id=link_id, type=found_type, outwardIssue=_linked_issue(inward_issue))
)
inward_issue.fields.issuelinks.append(
JiraIssueLink(id=str(get_next_issue_link_id()), type=found_type, inwardIssue=_linked_issue(outward_issue))
)
save_state()
return {
"id": link_id,
"type": _dump(found_type),
"inwardIssue": {"key": inward_issue.key, "summary": inward_issue.fields.summary},
"outwardIssue": {"key": outward_issue.key, "summary": outward_issue.fields.summary},
}
def search_fields(keyword: str = "", limit: LimitArg = 10, refresh: bool = False) -> list[dict[str, Any]]:
"""Search Jira fields by keyword."""
del refresh
fields = list(get_state().fields)
if keyword.strip():
lowered = keyword.lower()
fields = [field for field in fields if lowered in field.name.lower() or lowered in field.id.lower()]
return _dump(fields[:limit])
def get_link_types() -> dict[str, Any]:
"""Get all available issue link types."""
return {"issueLinkTypes": _dump(get_state().linkTypes)}
@@ -0,0 +1,150 @@
"""Board and sprint tool handlers."""
from __future__ import annotations
from typing import Any
from jira_mock.models import BoardType, JiraBoard, JiraDateTime, JiraSprint, ShortNameString, SprintState
from jira_mock.state import get_next_board_id, get_next_sprint_id, get_state, save_state
from jira_mock.tools.common import (
DEFAULT_READ_JIRA_FIELDS,
BoardIdArg,
LimitArg,
ProjectKeyArg,
SprintIdArg,
StartAtArg,
_dump,
_dump_issues,
_field,
_now,
require_admin,
)
def create_board(
project_key: ProjectKeyArg,
name: ShortNameString,
board_type: BoardType = "scrum",
filter_jql: str | None = None,
) -> dict[str, Any]:
"""Create a Jira board for an existing project. Requires is_admin=true."""
require_admin()
state = get_state()
if project_key not in state.projects:
raise ValueError(f"Project {project_key} not found")
board_id = get_next_board_id()
board = JiraBoard(
id=board_id,
name=name,
type=board_type,
projectKey=project_key,
filterJql=filter_jql or f"project = {project_key}",
)
state.boards[str(board_id)] = board
save_state()
return _dump(board)
def get_boards(
project_key: ProjectKeyArg | None = None,
board_type: BoardType | None = None,
startAt: StartAtArg = 0,
limit: LimitArg = 10,
) -> dict[str, Any]:
"""Get Jira boards, optionally filtered by project key or board type."""
boards = list(get_state().boards.values())
if project_key is not None:
boards = [board for board in boards if board.projectKey == project_key]
if board_type is not None:
boards = [board for board in boards if board.type == board_type]
return {
"maxResults": limit,
"startAt": startAt,
"total": len(boards),
"isLast": startAt + limit >= len(boards),
"values": _dump(boards[startAt : startAt + limit]),
}
def get_sprints_from_board(
board_id: BoardIdArg = "1000", state: SprintState | None = None, startAt: StartAtArg = 0, limit: LimitArg = 10
) -> dict[str, Any]:
"""Get Jira sprints from board by state."""
if board_id not in get_state().boards:
raise ValueError(f"Board {board_id} not found")
board_id_int = int(board_id)
sprints = [sprint for sprint in get_state().sprints.values() if sprint.originBoardId == board_id_int]
if state:
sprints = [sprint for sprint in sprints if sprint.state == state]
return {
"maxResults": limit,
"startAt": startAt,
"isLast": startAt + limit >= len(sprints),
"values": _dump(sprints[startAt : startAt + limit]),
}
def create_sprint(
board_id: BoardIdArg, sprint_name: str, start_date: JiraDateTime, end_date: JiraDateTime, goal: str | None = None
) -> dict[str, Any]:
"""Create Jira sprint for a board."""
state = get_state()
board = state.boards.get(board_id)
if board is None:
raise ValueError(f"Board {board_id} not found")
if board.type != "scrum":
raise ValueError("Sprints can only be created for Scrum boards")
sprint_id = get_next_sprint_id()
sprint = {
"id": sprint_id,
"state": "future",
"name": sprint_name,
"startDate": start_date,
"endDate": end_date,
"originBoardId": board.id,
"goal": goal,
}
state.sprints[str(sprint_id)] = JiraSprint.model_validate(sprint)
save_state()
return _dump(state.sprints[str(sprint_id)])
def get_sprint_issues(
sprint_id: SprintIdArg, fields: str = DEFAULT_READ_JIRA_FIELDS, startAt: StartAtArg = 0, limit: LimitArg = 10
) -> dict[str, Any]:
"""Get Jira issues from sprint."""
issues = [issue for issue in get_state().issues.values() if str(_field(issue, "customfield_10002")) == sprint_id]
return {
"startAt": startAt,
"maxResults": limit,
"total": len(issues),
"issues": _dump_issues(issues[startAt : startAt + limit], fields),
}
def update_sprint(
sprint_id: SprintIdArg,
sprint_name: str | None = None,
state: SprintState | None = None,
start_date: JiraDateTime | None = None,
end_date: JiraDateTime | None = None,
goal: str | None = None,
) -> dict[str, Any]:
"""Update Jira sprint."""
sprint = get_state().sprints.get(sprint_id)
if sprint is None:
raise ValueError(f"Sprint {sprint_id} not found")
if sprint_name:
sprint.name = sprint_name
if state:
sprint.state = state
if state == "closed":
sprint.completeDate = _now()
if start_date:
sprint.startDate = start_date
if end_date:
sprint.endDate = end_date
if goal is not None:
sprint.goal = goal
save_state()
return _dump(sprint)
@@ -0,0 +1,21 @@
"""State import/export tool handlers."""
from __future__ import annotations
from typing import Any
from jira_mock.state import list_sites as list_loaded_sites
from jira_mock.state import state_from_json, state_to_json
def export_state() -> dict[str, Any]:
return state_to_json()
def import_state(state: dict[str, Any]) -> dict[str, bool]:
state_from_json(state)
return {"ok": True}
def list_sites() -> dict[str, Any]:
return list_loaded_sites()
@@ -0,0 +1,108 @@
"""Time tracking tool handlers."""
from __future__ import annotations
import re
from typing import Any
from jira_mock.models import JiraDateTime, JiraTimeSpent, JiraTimeTracking, JiraWorklog
from jira_mock.state import get_next_worklog_id, get_state, save_state
from jira_mock.tools.common import IssueKey, _adf, _current_user, _dump, _now
def _parse_time_spent(time_spent: str) -> int:
total = 0
for value, unit in re.findall(r"(\d+)\s*([wdhm])", time_spent.lower()):
n = int(value)
total += {"w": n * 5 * 8 * 3600, "d": n * 8 * 3600, "h": n * 3600, "m": n * 60}[unit]
return total
def _format_seconds(seconds: int) -> str:
if seconds == 0:
return "0m"
hours = seconds // 3600
minutes = (seconds % 3600) // 60
parts = []
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
return " ".join(parts) or "0m"
def add_worklog(
issue_key: IssueKey, time_spent: JiraTimeSpent, comment: str | None = None, started: JiraDateTime | None = None
) -> dict[str, Any]:
"""Log time spent on a Jira issue."""
state = get_state()
issue = state.issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
seconds = _parse_time_spent(time_spent)
if seconds == 0:
raise ValueError(f"Invalid time format: {time_spent}. Use Jira notation like '2h', '1d 4h', '30m'.")
now = _now()
worklog = JiraWorklog.model_validate(
{
"id": str(get_next_worklog_id()),
"author": _dump(_current_user()),
"updateAuthor": _dump(_current_user()),
"comment": _adf(comment) if comment else None,
"created": now,
"updated": now,
"started": started or now,
"timeSpent": time_spent,
"timeSpentSeconds": seconds,
}
)
state.worklogs.setdefault(issue_key, []).append(worklog)
issue.fields.timetracking = issue.fields.timetracking or JiraTimeTracking()
spent = int(issue.fields.timetracking.timeSpentSeconds or 0) + seconds
issue.fields.timetracking.timeSpentSeconds = spent
issue.fields.timetracking.timeSpent = _format_seconds(spent)
if issue.fields.timetracking.remainingEstimateSeconds:
remaining = max(0, int(issue.fields.timetracking.remainingEstimateSeconds) - seconds)
issue.fields.timetracking.remainingEstimateSeconds = remaining
issue.fields.timetracking.remainingEstimate = _format_seconds(remaining)
issue.fields.updated = now
save_state()
return _dump(worklog)
def get_worklogs(issue_key: IssueKey) -> dict[str, Any]:
"""Get all work logs for a Jira issue."""
issue = get_state().issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
worklogs = get_state().worklogs.get(issue_key, [])
return {
"worklogs": _dump(worklogs),
"timetracking": _dump(issue.fields.timetracking or {}),
"totalTimeSpentSeconds": sum(w.timeSpentSeconds for w in worklogs),
}
def update_estimate(
issue_key: IssueKey, original_estimate: JiraTimeSpent, remaining_estimate: JiraTimeSpent | None = None
) -> dict[str, Any]:
"""Set or update time estimates for an issue."""
issue = get_state().issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
original = _parse_time_spent(original_estimate)
if original == 0:
raise ValueError(f"Invalid time format: {original_estimate}")
issue.fields.timetracking = issue.fields.timetracking or JiraTimeTracking()
issue.fields.timetracking.originalEstimate = original_estimate
issue.fields.timetracking.originalEstimateSeconds = original
if remaining_estimate:
issue.fields.timetracking.remainingEstimate = remaining_estimate
issue.fields.timetracking.remainingEstimateSeconds = _parse_time_spent(remaining_estimate)
else:
remaining = max(0, original - int(issue.fields.timetracking.timeSpentSeconds or 0))
issue.fields.timetracking.remainingEstimate = _format_seconds(remaining)
issue.fields.timetracking.remainingEstimateSeconds = remaining
issue.fields.updated = _now()
save_state()
return {"timetracking": _dump(issue.fields.timetracking)}
@@ -0,0 +1,68 @@
"""User tool handlers."""
from __future__ import annotations
from typing import Any
from pydantic import EmailStr
from jira_mock.models import AccountId, JiraTimeZone, JiraUser, ShortNameString
from jira_mock.state import get_state, save_state
from jira_mock.tools.common import LimitArg, StartAtArg, _current_user, _dump, require_admin
def create_user(
account_id: AccountId,
display_name: ShortNameString,
email_address: EmailStr | None = None,
active: bool = True,
time_zone: JiraTimeZone | None = "America/New_York",
) -> dict[str, Any]:
"""Create a Jira user. Requires is_admin=true."""
require_admin()
state = get_state()
if account_id in state.users:
raise ValueError(f"User {account_id} already exists")
user = JiraUser(
accountId=account_id,
accountType="atlassian",
emailAddress=email_address,
displayName=display_name,
active=active,
timeZone=time_zone,
)
state.users[account_id] = user
save_state()
return _dump(user)
def get_users(
query: str = "",
active: bool | None = None,
startAt: StartAtArg = 0,
limit: LimitArg = 10,
) -> dict[str, Any]:
"""Get Jira users, optionally filtered by query text or active status."""
users = list(get_state().users.values())
if active is not None:
users = [user for user in users if user.active is active]
if query.strip():
lowered = query.lower()
users = [
user
for user in users
if lowered in user.accountId.lower()
or lowered in user.displayName.lower()
or (user.emailAddress is not None and lowered in user.emailAddress.lower())
]
return {
"startAt": startAt,
"maxResults": limit,
"total": len(users),
"values": _dump(users[startAt : startAt + limit]),
}
def get_current_user() -> dict[str, Any]:
"""Get the Jira user whose account is currently authenticated for tool calls."""
return _dump(_current_user())
@@ -0,0 +1,116 @@
"""Workflow and status tool handlers."""
from __future__ import annotations
from typing import Any
from jira_mock.models import (
JiraStatus,
JiraStatusCategory,
JiraWorkflowTransitionConfig,
NumericIdString,
ShortNameString,
StatusCategoryKey,
)
from jira_mock.state import get_state, get_status_by_name, get_transitions_for_issue, save_state
from jira_mock.tools.common import IssueKey, TransitionIdArg, _dump, _now, require_admin
def get_transitions(issue_key: IssueKey) -> dict[str, Any]:
"""Get available workflow transitions for an issue."""
issue = get_state().issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
return {"transitions": get_transitions_for_issue(issue)}
def transition_issue(issue_key: IssueKey, transition_id: TransitionIdArg) -> dict[str, Any]:
"""Move an issue through a workflow transition."""
issue = get_state().issues.get(issue_key)
if issue is None:
raise ValueError(f"Issue {issue_key} not found")
transitions = get_transitions_for_issue(issue)
transition = next((item for item in transitions if item["id"] == transition_id), None)
if transition is None:
available = ", ".join(f"{item['id']}: {item['name']} -> {item['to']['name']}" for item in transitions)
raise ValueError(
f"Transition '{transition_id}' is not available for issue {issue_key} (current status: {issue.fields.status.name}). Available transitions: {available or 'none'}"
)
target = get_status_by_name(transition["to"]["name"])
if target is None:
raise ValueError(f"Unknown target status: {transition['to']['name']}")
issue.fields.status = target
issue.fields.updated = _now()
issue.fields.resolutiondate = _now() if target.statusCategory and target.statusCategory.key == "done" else None
save_state()
return _dump(issue)
def create_status(
status_id: NumericIdString,
name: ShortNameString,
status_category: StatusCategoryKey,
color_name: ShortNameString | None = None,
description: str | None = None,
) -> dict[str, Any]:
"""Create a Jira status. Requires is_admin=true in imported state."""
require_admin()
state = get_state()
if status_id in state.statuses:
raise ValueError(f"Status id {status_id} already exists")
if get_status_by_name(name) is not None:
raise ValueError(f"Status named {name!r} already exists")
category_names = {
"new": "To Do",
"indeterminate": "In Progress",
"done": "Done",
"undefined": "No Category",
}
category_ids = {"new": 2, "indeterminate": 4, "done": 3, "undefined": 1}
category_colors = {
"new": "blue-gray",
"indeterminate": "yellow",
"done": "green",
"undefined": "medium-gray",
}
status = JiraStatus(
id=status_id,
name=name,
description=description,
statusCategory=JiraStatusCategory(
id=category_ids[status_category],
key=status_category,
name=category_names[status_category],
colorName=color_name or category_colors[status_category],
),
)
state.statuses[status_id] = status
save_state()
return _dump(status)
def upsert_workflow_transition(
from_status: ShortNameString,
transition_id: NumericIdString,
transition_name: ShortNameString,
to_status: ShortNameString,
) -> dict[str, Any]:
"""Create or update a workflow transition between configured Jira statuses. Requires is_admin=true."""
require_admin()
state = get_state()
from_status_model = get_status_by_name(from_status)
if from_status_model is None:
raise ValueError(f"From status {from_status!r} is not configured")
to_status_model = get_status_by_name(to_status)
if to_status_model is None:
raise ValueError(f"To status {to_status!r} is not configured")
transition = JiraWorkflowTransitionConfig(id=transition_id, name=transition_name, to=to_status_model.name)
transitions = state.workflow.setdefault(from_status_model.name, [])
for index, existing in enumerate(transitions):
if existing.id == transition_id:
transitions[index] = transition
break
else:
transitions.append(transition)
save_state()
return _dump(transition)
@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Issues</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; color: #1e293b; }
.header { background: #fff; border-bottom: 1px solid #e2e8f0; padding: 12px 24px; display: flex; align-items: center; gap: 16px; }
.header h1 { font-size: 18px; font-weight: 600; }
.header .tabs { display: flex; gap: 4px; margin-left: auto; }
.header .tab { padding: 6px 14px; border-radius: 6px; border: none; background: transparent; color: #64748b; font-size: 13px; font-weight: 500; cursor: pointer; }
.header .tab.active { background: #eff6ff; color: #2563eb; }
.header .tab:hover { background: #f1f5f9; }
.filters { padding: 12px 24px; display: flex; gap: 8px; background: #fff; border-bottom: 1px solid #e2e8f0; }
.filters select, .filters input { padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; background: #fff; }
.filters select:focus, .filters input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }
.issue-list { padding: 0 24px; }
.issue-row { display: grid; grid-template-columns: 100px 1fr 120px 100px 140px; gap: 12px; align-items: center; padding: 10px 0; border-bottom: 1px solid #f1f5f9; cursor: pointer; font-size: 13px; }
.issue-row:hover { background: #f8fafc; }
.issue-row .key { color: #3b82f6; font-weight: 600; font-size: 12px; }
.issue-row .summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.issue-row .meta { color: #64748b; font-size: 12px; }
.status { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.02em; }
.status.done, .status.closed, .status.resolved { background: #dcfce7; color: #166534; }
.status.in-progress, .status.in_progress, .status.active { background: #dbeafe; color: #1e40af; }
.status.todo, .status.open, .status.new, .status.to-do { background: #f1f5f9; color: #475569; }
.status.blocked { background: #fee2e2; color: #991b1b; }
.priority { font-size: 11px; }
.priority.highest, .priority.critical { color: #dc2626; }
.priority.high { color: #ea580c; }
.priority.medium { color: #d97706; }
.priority.low { color: #2563eb; }
.priority.lowest { color: #64748b; }
.type-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-right: 6px; }
.type-badge.bug { background: #fee2e2; color: #991b1b; }
.type-badge.story { background: #dcfce7; color: #166534; }
.type-badge.task { background: #dbeafe; color: #1e40af; }
.type-badge.epic { background: #f3e8ff; color: #6b21a8; }
.kanban { display: flex; gap: 16px; padding: 16px 24px; overflow-x: auto; height: calc(100vh - 110px); }
.kanban-col { min-width: 280px; max-width: 320px; flex: 1; background: #f1f5f9; border-radius: 10px; display: flex; flex-direction: column; }
.kanban-col-header { padding: 12px 14px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #475569; display: flex; align-items: center; gap: 8px; }
.kanban-col-header .count { background: #cbd5e1; color: #334155; padding: 1px 7px; border-radius: 10px; font-size: 11px; }
.kanban-cards { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
.kanban-card { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; box-shadow: 0 1px 2px rgba(0,0,0,0.06); cursor: pointer; border: 1px solid #e2e8f0; }
.kanban-card:hover { border-color: #3b82f6; }
.kanban-card .card-key { font-size: 11px; color: #3b82f6; font-weight: 600; }
.kanban-card .card-summary { font-size: 13px; margin-top: 4px; line-height: 1.4; }
.kanban-card .card-meta { margin-top: 8px; display: flex; gap: 8px; align-items: center; font-size: 11px; color: #94a3b8; }
.detail-overlay { position: fixed; top: 0; right: 0; bottom: 0; width: 560px; background: #fff; box-shadow: -4px 0 20px rgba(0,0,0,0.1); z-index: 100; overflow-y: auto; transform: translateX(100%); transition: transform 0.2s ease; }
.detail-overlay.open { transform: translateX(0); }
.detail-header { padding: 20px 24px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: flex-start; }
.detail-header .close-btn { background: none; border: none; font-size: 20px; cursor: pointer; color: #94a3b8; padding: 4px; }
.detail-body { padding: 24px; }
.detail-body h2 { font-size: 18px; margin-bottom: 16px; line-height: 1.4; }
.detail-body .field { margin-bottom: 16px; }
.detail-body .field-label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: #64748b; margin-bottom: 4px; letter-spacing: 0.04em; }
.detail-body .field-value { font-size: 14px; line-height: 1.5; }
.detail-body .field-value.description { white-space: pre-wrap; color: #475569; }
.detail-body .comments { margin-top: 24px; border-top: 1px solid #e2e8f0; padding-top: 16px; }
.detail-body .comment { margin-bottom: 16px; padding: 12px; background: #f8fafc; border-radius: 8px; }
.detail-body .comment .comment-author { font-size: 13px; font-weight: 600; }
.detail-body .comment .comment-date { font-size: 11px; color: #94a3b8; margin-left: 8px; }
.detail-body .comment .comment-body { font-size: 13px; margin-top: 6px; line-height: 1.5; color: #475569; white-space: pre-wrap; }
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.2); z-index: 99; display: none; }
.backdrop.open { display: block; }
.empty { text-align: center; padding: 60px 24px; color: #94a3b8; font-size: 14px; }
</style>
</head>
<body>
<div class="header">
<h1>Issues</h1>
<div class="tabs">
<button class="tab active" data-view="list" onclick="setView('list')">List</button>
<button class="tab" data-view="kanban" onclick="setView('kanban')">Board</button>
</div>
</div>
<div class="filters">
<select id="filter-project" onchange="loadIssues()">
<option value="">All Projects</option>
</select>
<select id="filter-status" onchange="loadIssues()">
<option value="">All Statuses</option>
</select>
<input type="text" id="filter-search" placeholder="Search issues..." oninput="render()">
</div>
<div id="list-view" class="issue-list"></div>
<div id="kanban-view" class="kanban" style="display:none"></div>
<div id="backdrop" class="backdrop" onclick="closeDetail()"></div>
<div id="detail-panel" class="detail-overlay">
<div class="detail-header">
<div id="detail-key" style="font-size:13px;color:#3b82f6;font-weight:600"></div>
<button class="close-btn" onclick="closeDetail()">&times;</button>
</div>
<div class="detail-body" id="detail-body"></div>
</div>
<script>
let allIssues = [];
let currentView = 'list';
const base = window.location.pathname.replace(/\/$/, '');
async function fetchJSON(path) {
const response = await fetch(base + path);
if (!response.ok) throw new Error(path + ' returned ' + response.status);
return response.json();
}
async function init() {
const [projectData, statusData] = await Promise.all([
fetchJSON('/api/projects'),
fetchJSON('/api/statuses'),
]);
const projectSelect = document.getElementById('filter-project');
projectData.projects.forEach(project => {
const option = document.createElement('option');
option.value = project.key;
option.textContent = project.name + ' (' + project.key + ')';
projectSelect.appendChild(option);
});
const statusSelect = document.getElementById('filter-status');
statusData.statuses.forEach(status => {
const option = document.createElement('option');
option.value = status;
option.textContent = status;
statusSelect.appendChild(option);
});
await loadIssues();
}
async function loadIssues() {
const project = document.getElementById('filter-project').value;
const status = document.getElementById('filter-status').value;
const params = new URLSearchParams();
if (project) params.set('project', project);
if (status) params.set('status', status);
const query = params.toString();
const data = await fetchJSON('/api/issues' + (query ? '?' + query : ''));
allIssues = data.issues || [];
render();
}
function getFiltered() {
const search = document.getElementById('filter-search').value.toLowerCase();
if (!search) return allIssues;
return allIssues.filter(issue =>
issue.key.toLowerCase().includes(search) ||
issue.summary.toLowerCase().includes(search) ||
issue.assignee.toLowerCase().includes(search)
);
}
function render() {
if (currentView === 'list') renderList();
else renderKanban();
}
function renderList() {
const issues = getFiltered();
const el = document.getElementById('list-view');
if (!issues.length) {
el.innerHTML = '<div class="empty">No issues found</div>';
return;
}
el.innerHTML = issues.map(issue => `
<div class="issue-row" onclick="showDetail('${escAttr(issue.key)}')">
<div><span class="type-badge ${cssClass(issue.type)}">${esc(issue.type)}</span><span class="key">${esc(issue.key)}</span>${attachIndicator(issue)}</div>
<div class="summary">${esc(issue.summary)}</div>
<div><span class="status ${cssClass(issue.status)}">${esc(issue.status)}</span></div>
<div class="priority ${cssClass(issue.priority)}">${esc(issue.priority)}</div>
<div class="meta">${esc(issue.assignee)}</div>
</div>
`).join('');
}
function renderKanban() {
const issues = getFiltered();
const groups = {};
issues.forEach(issue => {
if (!groups[issue.status]) groups[issue.status] = [];
groups[issue.status].push(issue);
});
const order = ['To Do', 'Open', 'New', 'In Progress', 'In Review', 'Done', 'Closed', 'Resolved'];
const cols = Object.keys(groups).sort((a, b) => {
const ai = order.findIndex(item => item.toLowerCase() === a.toLowerCase());
const bi = order.findIndex(item => item.toLowerCase() === b.toLowerCase());
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
const el = document.getElementById('kanban-view');
if (!cols.length) {
el.innerHTML = '<div class="empty">No issues found</div>';
return;
}
el.innerHTML = cols.map(status => `
<div class="kanban-col">
<div class="kanban-col-header">
<span>${esc(status)}</span>
<span class="count">${groups[status].length}</span>
</div>
<div class="kanban-cards">
${groups[status].map(issue => `
<div class="kanban-card" onclick="showDetail('${escAttr(issue.key)}')">
<div class="card-key">${esc(issue.key)}${attachIndicator(issue)}</div>
<div class="card-summary">${esc(issue.summary)}</div>
<div class="card-meta">
<span class="type-badge ${cssClass(issue.type)}" style="margin:0">${esc(issue.type)}</span>
<span class="priority ${cssClass(issue.priority)}">${esc(issue.priority)}</span>
<span style="margin-left:auto">${esc(initials(issue.assignee))}</span>
</div>
</div>
`).join('')}
</div>
</div>
`).join('');
}
function setView(view) {
currentView = view;
document.getElementById('list-view').style.display = view === 'list' ? '' : 'none';
document.getElementById('kanban-view').style.display = view === 'kanban' ? '' : 'none';
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.view === view);
});
render();
}
async function showDetail(key) {
const data = await fetchJSON('/api/issues/' + encodeURIComponent(key));
const issue = data.issue;
document.getElementById('detail-key').textContent = issue.key;
let html = '<h2>' + esc(issue.summary) + '</h2>';
html += field('Status', '<span class="status ' + cssClass(issue.status) + '">' + esc(issue.status) + '</span>');
html += field('Type', esc(issue.type));
html += field('Priority', '<span class="priority ' + cssClass(issue.priority) + '">' + esc(issue.priority) + '</span>');
html += field('Assignee', esc(issue.assignee));
html += field('Reporter', esc(issue.reporter));
if (issue.labels && issue.labels.length) html += field('Labels', esc(issue.labels.join(', ')));
if (issue.description) html += field('Description', '<div class="field-value description">' + esc(issue.description) + '</div>', true);
if (issue.links && issue.links.length) {
html += field('Links', issue.links.map(link =>
'<div style="font-size:13px;margin-bottom:4px">' + esc(link.type) +
' <span style="color:#3b82f6;cursor:pointer" onclick="showDetail(\'' + escAttr(link.key) + '\')">' + esc(link.key) + '</span> ' +
esc(link.summary || '') + '</div>'
).join(''), true);
}
if (issue.attachments && issue.attachments.length) {
html += field('Attachments', issue.attachments.map(a =>
'<span style="display:inline-block;padding:4px 10px;margin:0 6px 4px 0;background:#f1f5f9;border-radius:6px;font-size:12px">📎 ' +
esc(a.filename) + ' <span style="color:#94a3b8">(' + fmtBytes(a.size) + ')</span></span>'
).join(''), true);
}
if (data.comments && data.comments.length) {
html += '<div class="comments"><div class="field-label">Comments (' + data.comments.length + ')</div>';
data.comments.forEach(comment => {
html += '<div class="comment"><span class="comment-author">' + esc(comment.author) + '</span><span class="comment-date">' + formatDate(comment.created) + '</span><div class="comment-body">' + esc(comment.body) + '</div></div>';
});
html += '</div>';
}
document.getElementById('detail-body').innerHTML = html;
document.getElementById('detail-panel').classList.add('open');
document.getElementById('backdrop').classList.add('open');
}
function closeDetail() {
document.getElementById('detail-panel').classList.remove('open');
document.getElementById('backdrop').classList.remove('open');
}
function field(label, value, raw) {
return '<div class="field"><div class="field-label">' + esc(label) + '</div>' + (raw ? value : '<div class="field-value">' + value + '</div>') + '</div>';
}
function fmtBytes(n) {
n = Number(n) || 0;
if (n < 1024) return n + ' B';
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
return (n / 1048576).toFixed(1) + ' MB';
}
function attachIndicator(issue) {
const count = issue.attachmentCount || 0;
if (!count) return '';
return ' <span title="' + count + ' attachment' + (count > 1 ? 's' : '') +
'" style="font-size:12px;color:#64748b">📎' + (count > 1 ? count : '') + '</span>';
}
function esc(value) {
if (value === null || value === undefined) return '';
const node = document.createElement('div');
node.textContent = String(value);
return node.innerHTML;
}
function escAttr(value) {
return String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
function cssClass(value) {
return String(value || '').toLowerCase().replace(/\s+/g, '-');
}
function initials(name) {
if (!name || name === 'Unassigned') return '?';
return name.split(' ').map(word => word[0]).join('').toUpperCase().slice(0, 2);
}
function formatDate(value) {
if (!value) return '';
try { return new Date(value).toLocaleDateString(); } catch { return value; }
}
init().catch(error => {
document.getElementById('list-view').innerHTML = '<div class="empty">' + esc(error.message) + '</div>';
});
</script>
</body>
</html>
@@ -0,0 +1,231 @@
"""Small HTTP viewer for the Jira mock."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import uvicorn
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import Route
from jira_mock.state import get_state
class ProxyAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
if request.url.path.startswith("/mcp"):
return await call_next(request)
proxy_token = os.environ.get("MCP_PROXY_TOKEN", "")
if proxy_token and request.headers.get("x-proxy-token") != proxy_token:
return JSONResponse({"error": "Forbidden: invalid proxy token"}, status_code=403)
return await call_next(request)
def _dump(value: Any) -> Any:
if hasattr(value, "model_dump"):
return value.model_dump(mode="json", by_alias=True, exclude_none=True)
if isinstance(value, list):
return [_dump(item) for item in value]
if isinstance(value, dict):
return {key: _dump(item) for key, item in value.items()}
return value
async def projects(_request: Request) -> JSONResponse:
state = get_state()
projects_data = [
{
"key": project.key,
"name": project.name,
"description": project.description,
"issueCount": len([key for key in state.issues if key.startswith(f"{project.key}-")]),
}
for project in state.projects.values()
]
return JSONResponse({"projects": projects_data})
def _extract_text(doc: Any) -> str:
if doc is None:
return ""
if isinstance(doc, str):
return doc
data = _dump(doc)
if not isinstance(data, dict):
return ""
content = data.get("content")
if not isinstance(content, list):
return ""
parts: list[str] = []
for block in content:
if not isinstance(block, dict):
continue
block_content = block.get("content")
if not isinstance(block_content, list):
continue
parts.append("".join(str(inline.get("text", "")) for inline in block_content if isinstance(inline, dict)))
return "\n".join(parts)
def _format_issue_summary(issue: Any) -> dict[str, Any]:
return {
"key": issue.key,
"summary": issue.fields.summary,
"status": issue.fields.status.name,
"statusCategory": issue.fields.status.statusCategory.key if issue.fields.status.statusCategory else "undefined",
"priority": issue.fields.priority.name if issue.fields.priority else "None",
"type": issue.fields.issuetype.name,
"assignee": issue.fields.assignee.displayName if issue.fields.assignee else "Unassigned",
"reporter": issue.fields.reporter.displayName if issue.fields.reporter else "",
"created": issue.fields.created,
"updated": issue.fields.updated,
"labels": issue.fields.labels or [],
"project": issue.fields.project.key,
"attachmentCount": len(issue.fields.attachment or []),
}
def _format_issue_detail(issue: Any) -> dict[str, Any]:
return {
**_format_issue_summary(issue),
"description": _extract_text(issue.fields.description),
"components": [component.name for component in issue.fields.components or []],
"links": [
{
"type": link.type.name,
"direction": "inward" if link.inwardIssue else "outward",
"key": (link.inwardIssue or link.outwardIssue).key if (link.inwardIssue or link.outwardIssue) else None,
"summary": (link.inwardIssue or link.outwardIssue).fields.summary
if (link.inwardIssue or link.outwardIssue) and (link.inwardIssue or link.outwardIssue).fields
else None,
}
for link in issue.fields.issuelinks or []
],
# Attachment metadata only -- the stored base64 ``content`` is never
# exposed by the viewer (parity with the get_attachments tool).
"attachments": [
{
"id": attachment.id,
"filename": attachment.filename,
"size": attachment.size,
"mimeType": attachment.mimeType,
"created": attachment.created,
"author": attachment.author.displayName if attachment.author else "",
}
for attachment in issue.fields.attachment or []
],
}
def _issue_extra_field(issue: Any, key: str) -> Any:
return (issue.fields.model_extra or {}).get(key)
async def issues(request: Request) -> JSONResponse:
issues_list = list(get_state().issues.values())
if project := request.query_params.get("project"):
issues_list = [issue for issue in issues_list if issue.key.startswith(f"{project}-")]
if status := request.query_params.get("status"):
issues_list = [issue for issue in issues_list if issue.fields.status.name.lower() == status.lower()]
if assignee := request.query_params.get("assignee"):
issues_list = [
issue
for issue in issues_list
if issue.fields.assignee and assignee.lower() in issue.fields.assignee.displayName.lower()
]
if issue_type := request.query_params.get("type"):
issues_list = [issue for issue in issues_list if issue.fields.issuetype.name.lower() == issue_type.lower()]
issues_list.sort(key=lambda issue: issue.fields.updated, reverse=True)
mapped = [_format_issue_summary(issue) for issue in issues_list]
return JSONResponse({"issues": mapped, "total": len(mapped)})
async def issue_detail(request: Request) -> JSONResponse:
state = get_state()
issue = state.issues.get(request.path_params["key"])
if issue is None:
return JSONResponse({"error": "Issue not found"}, status_code=404)
comments = [
{
"id": comment.id,
"author": comment.author.displayName,
"body": _extract_text(comment.body),
"created": comment.created,
}
for comment in state.comments.get(request.path_params["key"], [])
]
return JSONResponse({"issue": _format_issue_detail(issue), "comments": comments})
async def sprints(_request: Request) -> JSONResponse:
state = get_state()
sprints_data = [
{
"id": sprint.id,
"name": sprint.name,
"state": sprint.state,
"startDate": sprint.startDate,
"endDate": sprint.endDate,
"goal": sprint.goal,
"issueCount": len(
[
issue
for issue in state.issues.values()
if _issue_extra_field(issue, "customfield_10002") == sprint.id
or (_dump(_issue_extra_field(issue, "sprint") or {}).get("id") == sprint.id)
]
),
}
for sprint in state.sprints.values()
]
return JSONResponse({"sprints": sprints_data})
async def statuses(_request: Request) -> JSONResponse:
statuses_data = sorted({issue.fields.status.name for issue in get_state().issues.values()})
return JSONResponse({"statuses": statuses_data})
async def index(_request: Request) -> HTMLResponse:
return HTMLResponse(VIEWER_HTML)
VIEWER_HTML = (Path(__file__).parent / "viewer.html").read_text(encoding="utf-8")
def create_app() -> Starlette:
routes = [
Route("/", index),
Route("/api/projects", projects),
Route("/api/issues", issues),
Route("/api/issues/{key}", issue_detail),
Route("/api/sprints", sprints),
Route("/api/statuses", statuses),
]
return Starlette(routes=routes, middleware=[Middleware(ProxyAuthMiddleware)])
def run_http_server(_mcp, port: int) -> None:
if hasattr(_mcp, "streamable_http_app"):
fastmcp_asgi = _mcp.streamable_http_app()
else:
fastmcp_asgi = _mcp.http_app(transport="streamable-http", path="/mcp")
viewer = create_app()
async def combined_app(scope, receive, send):
if scope["type"] == "lifespan":
await fastmcp_asgi(scope, receive, send)
return
if scope.get("path", "").startswith("/mcp"):
await fastmcp_asgi(scope, receive, send)
else:
await viewer(scope, receive, send)
uvicorn.run(combined_app, host="127.0.0.1", port=port, log_level="warning")
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import pytest
from jira_mock.state import reset_state
@pytest.fixture(autouse=True)
def clean_state() -> None:
reset_state()
@@ -0,0 +1,233 @@
from __future__ import annotations
import json
from pathlib import Path
import jira_mock.state as state_mod
from jira_mock.state import (
load_state,
resolve_bundle_output_path,
resolve_bundle_state_path,
resolve_bundle_state_paths,
set_agent_workspace,
state_to_json,
)
def _flat_site(account_id: str) -> dict:
"""A minimal valid flat single-site seed keyed on a distinct user."""
return {
"currentUserAccountId": account_id,
"users": {
account_id: {
"accountId": account_id,
"accountType": "atlassian",
"emailAddress": f"{account_id}@example.com",
"displayName": f"User {account_id}",
"active": True,
"timeZone": "America/New_York",
}
},
}
def test_resolve_bundle_state_path_prefers_state_json(tmp_path: Path, monkeypatch) -> None:
"""state.json wins over a bare *.json sibling in the per-service subdir."""
service_dir = tmp_path / "services" / "jira"
service_dir.mkdir(parents=True)
(service_dir / "state.json").write_text("{}", encoding="utf-8")
(service_dir / "issues.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_path() == service_dir / "state.json"
def test_resolve_bundle_state_path_globs_when_no_state_json(tmp_path: Path, monkeypatch) -> None:
"""The singular back-compat accessor returns the first *.json when there's
no state.json. (The loader itself reads the whole folder — see
resolve_bundle_state_paths.)"""
service_dir = tmp_path / "services" / "jira"
service_dir.mkdir(parents=True)
(service_dir / "b.json").write_text("{}", encoding="utf-8")
(service_dir / "a.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_path() == service_dir / "a.json"
def test_resolve_bundle_state_path_missing_subdir(tmp_path: Path, monkeypatch) -> None:
"""A partial bundle without this service's subdir resolves to None so the
loader falls back to INPUTDIR."""
(tmp_path / "services").mkdir()
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_path() is None
monkeypatch.delenv("BUNDLEDIR")
assert resolve_bundle_state_path() is None
def test_resolve_bundle_output_path(monkeypatch) -> None:
monkeypatch.setenv("BUNDLE_OUTPUT_DIR", "/some/output/services/jira")
assert resolve_bundle_output_path() == Path("/some/output/services/jira/state.json")
monkeypatch.delenv("BUNDLE_OUTPUT_DIR")
assert resolve_bundle_output_path() is None
def _reset_loader(workspace: Path) -> None:
"""Force a clean reload from env the way load_state() guards on globals."""
state_mod._current_state = None
state_mod._sites.clear()
set_agent_workspace(str(workspace / "agent_workspace"))
def test_bundle_state_json_matches_inputdir(tmp_path: Path, monkeypatch) -> None:
"""Loading the same seed from <BUNDLEDIR>/services/jira/state.json yields the
same canonical state as loading it from INPUTDIR."""
for var in ("BUNDLEDIR", "INPUTDIR", "OUTPUTDIR", "BUNDLE_OUTPUT_DIR"):
monkeypatch.delenv(var, raising=False)
seed = {
"currentUserAccountId": "user-1",
"users": {
"user-1": {
"accountId": "user-1",
"accountType": "atlassian",
"emailAddress": "user-1@example.com",
"displayName": "User 1",
"active": True,
"timeZone": "America/New_York",
}
},
}
seed_text = json.dumps(seed)
bundle_dir = tmp_path / "bundle"
bundle_state = bundle_dir / "services" / "jira" / "state.json"
bundle_state.parent.mkdir(parents=True)
bundle_state.write_text(seed_text, encoding="utf-8")
input_dir = tmp_path / "input"
input_dir.mkdir()
(input_dir / "jira.json").write_text(seed_text, encoding="utf-8")
# Load from bundle (INPUTDIR unset).
_reset_loader(tmp_path / "bundle_ws")
monkeypatch.setenv("BUNDLEDIR", str(bundle_dir))
monkeypatch.delenv("INPUTDIR", raising=False)
load_state()
from_bundle = state_to_json()
# Load from INPUTDIR (BUNDLEDIR unset).
_reset_loader(tmp_path / "input_ws")
monkeypatch.delenv("BUNDLEDIR", raising=False)
monkeypatch.setenv("INPUTDIR", str(input_dir))
load_state()
from_inputdir = state_to_json()
assert from_bundle == from_inputdir
def test_resolve_bundle_state_paths_returns_whole_folder(tmp_path: Path, monkeypatch) -> None:
"""The plural resolver returns ALL sorted *.json when there's no state.json,
and just [state.json] when one is present."""
service_dir = tmp_path / "services" / "jira"
service_dir.mkdir(parents=True)
(service_dir / "b.json").write_text("{}", encoding="utf-8")
(service_dir / "a.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_paths() == [service_dir / "a.json", service_dir / "b.json"]
(service_dir / "state.json").write_text("{}", encoding="utf-8")
assert resolve_bundle_state_paths() == [service_dir / "state.json"]
def test_bundle_multifile_folder_matches_consolidated_state(tmp_path: Path, monkeypatch) -> None:
"""A split multi-file bundle folder coalesces to the same canonical state as a
single consolidated multi-site state.json."""
for var in ("BUNDLEDIR", "INPUTDIR", "OUTPUTDIR", "BUNDLE_OUTPUT_DIR"):
monkeypatch.delenv(var, raising=False)
site_a = _flat_site("user-a")
site_b = _flat_site("user-b")
# Make site B distinguishable beyond its current user via an extra user.
site_b["users"]["user-b-extra"] = {
"accountId": "user-b-extra",
"accountType": "atlassian",
"emailAddress": "user-b-extra@example.com",
"displayName": "User B Extra",
"active": True,
"timeZone": "America/New_York",
}
# (a) consolidated: one state.json with both sites.
consolidated = tmp_path / "consolidated"
consolidated_state = consolidated / "services" / "jira" / "state.json"
consolidated_state.parent.mkdir(parents=True)
consolidated_state.write_text(
json.dumps({"sites": {"default": site_a, "extra": site_b}}),
encoding="utf-8",
)
# (b) split: two {sites} wrapper files, no state.json — two distinct named
# sites across files (vs. the flat-file case, which merges into one site).
split = tmp_path / "split"
split_dir = split / "services" / "jira"
split_dir.mkdir(parents=True)
(split_dir / "a.json").write_text(json.dumps({"sites": {"default": site_a}}), encoding="utf-8")
(split_dir / "b.json").write_text(json.dumps({"sites": {"extra": site_b}}), encoding="utf-8")
# Load consolidated.
_reset_loader(tmp_path / "consolidated_ws")
monkeypatch.setenv("BUNDLEDIR", str(consolidated))
load_state()
from_consolidated = state_to_json()
# Load split.
_reset_loader(tmp_path / "split_ws")
monkeypatch.setenv("BUNDLEDIR", str(split))
load_state()
from_split = state_to_json()
assert from_consolidated == from_split
assert set(from_consolidated["sites"]) == {"default", "extra"}
assert set(from_split["sites"]) == {"default", "extra"}
def test_bundle_flat_files_merge_into_one_site(tmp_path: Path, monkeypatch) -> None:
"""the raw entities layout splits ONE site across per-entity files (no
{sites} wrapper). Flat files must merge into a single default site, not
fragment into a phantom site per file the server never activates."""
for var in ("BUNDLEDIR", "INPUTDIR", "OUTPUTDIR", "BUNDLE_OUTPUT_DIR"):
monkeypatch.delenv(var, raising=False)
service_dir = tmp_path / "bundle" / "services" / "jira"
service_dir.mkdir(parents=True)
# Two halves of the SAME site: identity/users in one file, an extra user in
# another. Both must land in the single active (default) site.
(service_dir / "a_users.json").write_text(json.dumps(_flat_site("user-a")), encoding="utf-8")
(service_dir / "b_users.json").write_text(
json.dumps(
{
"users": {
"user-b": {
"accountId": "user-b",
"accountType": "atlassian",
"emailAddress": "user-b@example.com",
"displayName": "User B",
"active": True,
"timeZone": "America/New_York",
}
}
}
),
encoding="utf-8",
)
_reset_loader(tmp_path / "flat_ws")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "bundle"))
load_state()
merged = state_to_json()
# One site, and both per-file users present in it — nothing fragmented away.
assert set(merged.get("sites", {"default": merged}).keys()) == {"default"} or "users" in merged
state = merged["sites"]["default"] if "sites" in merged else merged
assert "user-a" in state["users"]
assert "user-b" in state["users"]
+479
View File
@@ -0,0 +1,479 @@
from __future__ import annotations
import json
from copy import deepcopy
import pytest
from jira_mock.server import (
add_attachment,
add_comment,
add_worklog,
create_issue,
create_sprint,
create_user,
delete_issue,
export_state,
get_current_user,
get_epic_issues,
get_issue,
get_project_issues,
get_users,
import_state,
link_issues,
search,
search_fields,
update_issue,
)
from jira_mock.state import get_state
@pytest.mark.asyncio
async def test_create_get_update_and_delete_issue() -> None:
created = await create_issue(
"MOCK", "Initial summary", "Task", description="Initial body", components="API, Frontend"
)
assert created["key"] == "MOCK-1"
issue = await get_issue("MOCK-1", fields="summary,description,components")
assert issue["fields"]["summary"] == "Initial summary"
assert issue["fields"]["description"]["content"][0]["content"][0]["text"] == "Initial body"
assert [component["name"] for component in issue["fields"]["components"]] == ["API", "Frontend"]
updated = await update_issue("MOCK-1", json.dumps({"summary": "Updated summary", "labels": ["ops", "urgent"]}))
assert updated["fields"]["summary"] == "Updated summary"
assert updated["fields"]["labels"] == ["ops", "urgent"]
assert (await delete_issue("MOCK-1")) == {"message": "Issue MOCK-1 deleted successfully"}
with pytest.raises(ValueError, match="Issue MOCK-1 not found"):
await get_issue("MOCK-1")
@pytest.mark.asyncio
async def test_create_issue_rejects_malformed_additional_fields_and_rolls_back() -> None:
with pytest.raises(ValueError, match="Invalid JSON in `additional_fields` parameter"):
await create_issue("MOCK", "Bad additional fields", "Task", additional_fields="{not json")
assert (await search("project = MOCK", limit=10))["total"] == 0
@pytest.mark.asyncio
async def test_create_issue_uses_max_existing_issue_key_suffix() -> None:
await create_issue("MOCK", "First", "Task")
state = await export_state()
base_issue = state["issues"]["MOCK-1"]
for suffix in (3, 5):
issue = deepcopy(base_issue)
issue["id"] = str(10000 + suffix)
issue["key"] = f"MOCK-{suffix}"
issue["self"] = f"https://api.atlassian.com/ex/jira/mock/rest/api/3/issue/MOCK-{suffix}"
issue["fields"]["summary"] = f"Sparse issue {suffix}"
state["issues"][issue["key"]] = issue
await import_state(state)
created = await create_issue("MOCK", "After sparse keys", "Task")
assert created["key"] == "MOCK-6"
assert (await get_issue("MOCK-5"))["fields"]["summary"] == "Sparse issue 5"
@pytest.mark.asyncio
async def test_seeded_state_recovers_numeric_counters() -> None:
await create_issue("MOCK", "Seed", "Task")
await create_issue("MOCK", "Seed link target", "Task")
await link_issues("MOCK-1", "MOCK-2", "Blocks")
await add_comment("MOCK-1", "seed comment")
await add_worklog("MOCK-1", "1h", started="2026-05-08T14:30:00Z")
await add_attachment("MOCK-1", "seed.txt", "aGk=")
await create_sprint("1000", "Seed sprint", "2026-05-01T00:00:00Z", "2026-05-15T00:00:00Z")
state = await export_state()
state["issues"]["MOCK-1"]["id"] = "10050"
state["issues"]["MOCK-1"]["fields"]["project"]["id"] = "10020"
state["issues"]["MOCK-2"]["fields"]["project"]["id"] = "10020"
state["projects"]["MOCK"]["id"] = "10020"
state["comments"]["MOCK-1"][0]["id"] = "42"
state["worklogs"]["MOCK-1"][0]["id"] = "24"
state["issues"]["MOCK-1"]["fields"]["attachment"][0]["id"] = "88"
state["issues"]["MOCK-1"]["fields"]["issuelinks"][0]["id"] = "78"
state["issues"]["MOCK-2"]["fields"]["issuelinks"][0]["id"] = "77"
[sprint] = state["sprints"].values()
sprint["id"] = 1007
sprint["self"] = "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1007"
state["sprints"] = {"1007": sprint}
state["counters"] = {
"issueId": 1,
"sprintId": 1,
"commentId": 1,
"worklogId": 1,
"attachmentId": 1,
"issueLinkId": 1,
}
await import_state(state)
assert (await create_issue("MOCK", "Next issue id", "Task"))["id"] == "10051"
assert (await add_comment("MOCK-1", "next comment"))["id"] == "43"
assert (await add_worklog("MOCK-1", "30m", started="2026-05-08T15:30:00Z"))["id"] == "25"
assert (await add_attachment("MOCK-1", "next.txt", "b2s="))["id"] == "89"
assert (await create_sprint("1000", "Next sprint", "2026-06-01T00:00:00Z", "2026-06-15T00:00:00Z"))["id"] == 1008
assert (await create_issue("ABC", "New project", "Task"))["key"] == "ABC-1"
assert (await get_issue("ABC-1", fields="project"))["fields"]["project"]["id"] == "10021"
assert (await link_issues("MOCK-1", "ABC-1", "Blocks"))["id"] == "79"
@pytest.mark.asyncio
async def test_fresh_state_small_id_counters_start_at_one() -> None:
await create_issue("MOCK", "First", "Task")
await create_issue("MOCK", "Second", "Task")
assert (await add_comment("MOCK-1", "first comment"))["id"] == "1"
assert (await add_worklog("MOCK-1", "1h", started="2026-05-08T14:30:00Z"))["id"] == "1"
assert (await add_attachment("MOCK-1", "first.txt", "aGk="))["id"] == "1"
assert (await link_issues("MOCK-1", "MOCK-2", "Blocks"))["id"] == "1"
@pytest.mark.asyncio
async def test_update_issue_rejects_direct_status_change() -> None:
await create_issue("MOCK", "Needs transition", "Task")
with pytest.raises(ValueError, match="Cannot change status directly"):
await update_issue("MOCK-1", json.dumps({"status": "Done"}))
@pytest.mark.asyncio
async def test_update_issue_rejects_malformed_json_only_as_invalid_json() -> None:
await create_issue("MOCK", "Needs valid json", "Task")
with pytest.raises(ValueError, match="Invalid JSON"):
await update_issue("MOCK-1", "{not json")
@pytest.mark.asyncio
async def test_update_issue_rejects_invalid_field_shapes_and_rolls_back() -> None:
await create_issue("MOCK", "Original summary", "Task")
with pytest.raises(ValueError):
await update_issue("MOCK-1", json.dumps({"summary": "Changed", "labels": "not-a-list"}))
issue = await get_issue("MOCK-1", fields="summary,labels")
assert issue["fields"]["summary"] == "Original summary"
assert issue["fields"]["labels"] == []
@pytest.mark.asyncio
async def test_update_issue_rejects_unsupported_fields_and_rolls_back() -> None:
await create_issue("MOCK", "Original summary", "Task")
with pytest.raises(ValueError, match="Unsupported update_issue field"):
await update_issue("MOCK-1", json.dumps({"summary": "Changed", "fixVersions": [{"name": "v1"}]}))
issue = await get_issue("MOCK-1", fields="summary,fixVersions")
assert issue["fields"]["summary"] == "Original summary"
assert issue["fields"]["fixVersions"] == []
@pytest.mark.asyncio
async def test_update_issue_can_clear_optional_fields() -> None:
(
await create_issue(
"MOCK",
"Clearable",
"Task",
description="Initial description",
assignee="user-1",
additional_fields=json.dumps({"labels": ["triage"], "priority": {"id": "2", "name": "High"}}),
)
)
updated = await update_issue(
"MOCK-1",
json.dumps({"description": "", "assignee": None, "priority": None, "labels": []}),
)
assert updated["fields"]["description"]["content"][0]["content"][0]["text"] == ""
assert updated["fields"].get("assignee") is None
assert updated["fields"].get("priority") is None
assert updated["fields"]["labels"] == []
@pytest.mark.asyncio
async def test_users_are_state_level_identities() -> None:
users = await get_users(query="user-1")
assert users["total"] == 1
assert users["values"][0]["accountId"] == "user-1"
assert "self" not in users["values"][0]
assert (await get_current_user())["accountId"] == "reporter-001"
assert "self" not in (await get_current_user())
with pytest.raises(ValueError, match="User missing-user not found"):
await create_issue("MOCK", "Unknown assignee", "Task", assignee="missing-user")
state = await export_state()
state["is_admin"] = True
await import_state(state)
created_user = await create_user("pm-001", "Product Manager", "pm@example.com")
assert created_user["accountId"] == "pm-001"
assert "self" not in created_user
await create_issue("MOCK", "Assigned issue", "Task", assignee="pm-001")
issue = await get_issue("MOCK-1", fields="assignee,reporter,creator")
assert issue["fields"]["assignee"]["displayName"] == "Product Manager"
assert issue["fields"]["reporter"]["accountId"] == "reporter-001"
assert issue["fields"]["creator"]["accountId"] == "reporter-001"
@pytest.mark.asyncio
async def test_imported_users_replace_defaults_and_define_current_user() -> None:
state = await export_state()
state["users"] = {
"alice": {
"accountId": "alice",
"displayName": "Alice",
"emailAddress": "alice@example.com",
"timeZone": "America/Los_Angeles",
"active": True,
}
}
state.pop("currentUserAccountId", None)
await import_state(state)
assert (await get_users())["total"] == 1
assert (await get_current_user())["accountId"] == "alice"
created = await create_issue("MOCK", "Alice-authored issue", "Task")
issue = await get_issue(created["key"], fields="reporter,creator")
assert issue["fields"]["reporter"]["accountId"] == "alice"
assert issue["fields"]["creator"]["accountId"] == "alice"
@pytest.mark.asyncio
async def test_jql_current_user_uses_configured_current_user() -> None:
state = await export_state()
state["currentUserAccountId"] = "user-1"
await import_state(state)
await create_issue("MOCK", "Mine", "Task", assignee="user-1")
await create_issue("MOCK", "Unassigned", "Task")
result = await search("assignee = currentUser()", limit=10)
assert result["total"] == 1
assert result["issues"][0]["fields"]["summary"] == "Mine"
@pytest.mark.asyncio
async def test_jql_current_user_matches_nothing_when_current_user_is_missing() -> None:
await create_issue("MOCK", "Assigned issue", "Task", assignee="user-1")
get_state().currentUserAccountId = None
result = await search("assignee = currentUser()", limit=10)
assert result["total"] == 0
assert "currentUser() was not applied because no current Jira user is configured." in result["warningMessages"]
@pytest.mark.asyncio
async def test_imported_state_requires_a_current_user() -> None:
state = await export_state()
state["users"] = {}
state.pop("currentUserAccountId", None)
with pytest.raises(ValueError, match="requires at least one user"):
await import_state(state)
@pytest.mark.asyncio
async def test_user_email_and_time_zone_are_validated() -> None:
state = await export_state()
state["is_admin"] = True
await import_state(state)
with pytest.raises(ValueError):
await create_user("bad-email", "Bad Email", "not-an-email")
with pytest.raises(ValueError):
await create_user("bad-zone", "Bad Zone", "zone@example.com", time_zone="New York")
@pytest.mark.asyncio
async def test_non_admin_cannot_create_users() -> None:
with pytest.raises(PermissionError):
await create_user("qa-001", "QA User")
@pytest.mark.asyncio
async def test_update_issue_rejects_empty_summary() -> None:
await create_issue("MOCK", "Original summary", "Task")
with pytest.raises(ValueError):
await update_issue("MOCK-1", json.dumps({"summary": ""}))
assert (await get_issue("MOCK-1"))["fields"]["summary"] == "Original summary"
@pytest.mark.asyncio
async def test_search_filters_text_project_labels_and_order() -> None:
first_mock = await create_issue("MOCK", "Fix checkout error", "Bug", description="Payment screen timeout")
await create_issue("TEST", "Write release plan", "Task", additional_fields=json.dumps({"labels": ["planning"]}))
second_mock = await create_issue(
"MOCK", "Checkout polish", "Task", additional_fields=json.dumps({"labels": ["frontend"]})
)
state = await export_state()
base_issue = deepcopy(state["issues"][first_mock["key"]])
for suffix in range(3, 11):
issue = deepcopy(base_issue)
issue["id"] = str(20000 + suffix)
issue["key"] = f"MOCK-{suffix}"
issue["fields"]["summary"] = f"Natural sort issue {suffix}"
state["issues"][issue["key"]] = issue
await import_state(state)
assert (await search('project = MOCK AND summary ~ "checkout"', limit=10))["total"] == 2
assert (await search("labels = planning", limit=10))["issues"][0]["key"] == "TEST-1"
by_key = await search("key = MOCK-1", limit=10)
assert by_key["warningMessages"] == []
assert by_key["total"] == 1
assert by_key["issues"][0]["key"] == "MOCK-1"
by_issuekey = await search("issuekey in (MOCK-1, TEST-1)", limit=10)
assert [issue["key"] for issue in by_issuekey["issues"]] == ["MOCK-1", "TEST-1"]
ordered = (await search("project in (MOCK, TEST) order by key desc", limit=20))["issues"]
assert [issue["key"] for issue in ordered] == [
"TEST-1",
"MOCK-10",
"MOCK-9",
"MOCK-8",
"MOCK-7",
"MOCK-6",
"MOCK-5",
"MOCK-4",
"MOCK-3",
second_mock["key"],
first_mock["key"],
]
@pytest.mark.asyncio
async def test_issue_tools_honor_fields_filter() -> None:
await create_issue("MOCK", "Filtered", "Task", description="Hidden unless requested")
result = (await search("project = MOCK", fields="summary", limit=10))["issues"][0]
assert result["fields"] == {"summary": "Filtered"}
issue = await get_issue("MOCK-1", fields="summary,description")
assert set(issue["fields"]) == {"summary", "description"}
assert issue["fields"]["description"]["content"][0]["content"][0]["text"] == "Hidden unless requested"
full_issue = await get_issue("MOCK-1", fields="*all")
assert "status" in full_issue["fields"]
assert "project" in full_issue["fields"]
@pytest.mark.asyncio
async def test_search_limit_warnings_are_compatible_with_ts_behavior() -> None:
await create_issue("MOCK", "One", "Task")
result = await search("project = MOCK", limit=-1, startAt=-2)
assert result["issues"] == []
assert "limit must be non-negative; using 0." in result["warningMessages"]
assert "startAt must be non-negative; using 0." in result["warningMessages"]
@pytest.mark.asyncio
async def test_project_and_epic_issue_helpers() -> None:
epic = await create_issue("MOCK", "Epic", "Epic")
child = await create_issue("MOCK", "Story", "Story", additional_fields=json.dumps({"parent": epic["key"]}))
assert (await get_project_issues("MOCK"))["total"] == 2
epic_issues = await get_epic_issues(epic["key"])
assert epic_issues["total"] == 1
assert epic_issues["issues"][0]["key"] == child["key"]
@pytest.mark.asyncio
async def test_link_issues_creates_bidirectional_links() -> None:
await create_issue("MOCK", "Blocked", "Task")
await create_issue("MOCK", "Blocker", "Task")
result = await link_issues("MOCK-1", "MOCK-2", "Blocks")
duplicate = await link_issues("MOCK-1", "MOCK-2", "Blocks")
reciprocal_duplicate = await link_issues("MOCK-2", "MOCK-1", "is blocked by")
assert duplicate["id"] == result["id"]
assert reciprocal_duplicate["id"] == result["id"]
assert result["type"]["name"] == "Blocks"
assert (await get_issue("MOCK-1", fields="issuelinks"))["fields"]["issuelinks"][0]["inwardIssue"]["key"] == "MOCK-2"
assert (await get_issue("MOCK-2", fields="issuelinks"))["fields"]["issuelinks"][0]["outwardIssue"][
"key"
] == "MOCK-1"
assert len((await get_issue("MOCK-1", fields="issuelinks"))["fields"]["issuelinks"]) == 1
assert len((await get_issue("MOCK-2", fields="issuelinks"))["fields"]["issuelinks"]) == 1
@pytest.mark.asyncio
async def test_link_issues_rejects_self_links() -> None:
await create_issue("MOCK", "Circular", "Task")
with pytest.raises(ValueError, match="Cannot link an issue to itself"):
await link_issues("MOCK-1", "MOCK-1", "Blocks")
assert "issuelinks" not in (await get_issue("MOCK-1", fields="issuelinks"))["fields"]
@pytest.mark.asyncio
async def test_delete_issue_removes_dangling_issue_references() -> None:
parent = await create_issue("MOCK", "Parent", "Epic")
child = await create_issue("MOCK", "Child", "Story", additional_fields=json.dumps({"parent": parent["key"]}))
peer = await create_issue("MOCK", "Peer", "Task")
await link_issues(child["key"], peer["key"], "Blocks")
assert (await get_issue(child["key"], fields="parent"))["fields"]["parent"]["key"] == parent["key"]
assert (await get_issue(child["key"], fields="issuelinks"))["fields"]["issuelinks"][0]["inwardIssue"][
"key"
] == peer["key"]
await delete_issue(parent["key"])
assert "parent" not in (await get_issue(child["key"], fields="parent"))["fields"]
await delete_issue(peer["key"])
assert (await get_issue(child["key"], fields="issuelinks"))["fields"]["issuelinks"] == []
@pytest.mark.asyncio
async def test_api_and_state_dump_use_jira_aliases() -> None:
await create_issue("MOCK", "Alias issue", "Task")
state = await export_state()
state["users"]["reporter-001"]["self"] = (
"https://api.atlassian.com/ex/jira/mock/rest/api/3/user?accountId=reporter-001"
)
state["projects"]["MOCK"]["self"] = "https://api.atlassian.com/ex/jira/mock/rest/api/3/project/MOCK"
state["issues"]["MOCK-1"]["self"] = "https://api.atlassian.com/ex/jira/mock/rest/api/3/issue/MOCK-1"
state["fields"].append(
{
"id": "customfield_10050",
"key": "customfield_10050",
"name": "Alias Field",
"custom": True,
"schema": {"type": "number", "customId": 10050},
}
)
state["issues"]["MOCK-1"]["changelog"] = {
"histories": [
{
"id": "1",
"created": "2026-05-08T14:30:00Z",
"items": [
{"field": "status", "from": "10001", "fromString": "To Do", "to": "10002", "toString": "Done"}
],
}
]
}
await import_state(state)
field = (await search_fields("Alias Field"))[0]
assert field["schema"]["type"] == "number"
assert "schema_" not in field
exported_item = (await export_state())["issues"]["MOCK-1"]["changelog"]["histories"][0]["items"][0]
assert exported_item["from"] == "10001"
assert "from_" not in exported_item
assert '"self"' not in json.dumps(await export_state())
@@ -0,0 +1,58 @@
from __future__ import annotations
import pytest
from jira_mock.server import create_issue, get_project_issues, list_sites, search, update_issue
from jira_mock.state import get_active_site_id, state_from_json, state_to_json
@pytest.mark.asyncio
async def test_site_selector_routes_reads_and_writes_independently() -> None:
state_from_json({"sites": {"default": {}, "acme": {}}})
default_issue = await create_issue("MOCK", "Default site issue", "Task", site_id="default")
acme_issue = await create_issue("MOCK", "Acme site issue", "Task", site_id="acme")
assert default_issue["key"] == "MOCK-1"
assert acme_issue["key"] == "MOCK-1"
listed = await list_sites()
assert listed["status"] == "success"
assert listed["total"] == 2
assert {site["site_id"] for site in listed["sites"]} == {"default", "acme"}
default_issues = await get_project_issues("MOCK", site_id="default")
acme_issues = await get_project_issues("MOCK", site_id="acme")
assert default_issues["issues"][0]["fields"]["summary"] == "Default site issue"
assert acme_issues["issues"][0]["fields"]["summary"] == "Acme site issue"
assert (await search('summary ~ "Acme"', site_id="acme"))["total"] == 1
assert (await search('summary ~ "Acme"', site_id="default"))["total"] == 0
exported = state_to_json()
assert set(exported["sites"]) == {"default", "acme"}
assert exported["sites"]["default"]["issues"]["MOCK-1"]["fields"]["summary"] == "Default site issue"
assert exported["sites"]["acme"]["issues"]["MOCK-1"]["fields"]["summary"] == "Acme site issue"
@pytest.mark.asyncio
async def test_failed_site_write_does_not_mutate_other_sites() -> None:
state_from_json({"sites": {"default": {}, "acme": {}}})
await create_issue("MOCK", "Default site issue", "Task", site_id="default")
before = state_to_json()
with pytest.raises(ValueError, match="Issue MISSING-1 not found"):
await update_issue("MISSING-1", "{}", site_id="acme")
assert state_to_json() == before
@pytest.mark.asyncio
async def test_state_import_resets_active_site_to_default() -> None:
state_from_json({"sites": {"default": {}, "acme": {}}})
await get_project_issues("MOCK", site_id="acme")
assert get_active_site_id() == "acme"
state_from_json({"sites": {"default": {}}})
assert get_active_site_id() == "default"
assert (await get_project_issues("MOCK"))["total"] == 0
+659
View File
@@ -0,0 +1,659 @@
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
import pytest
from jira_mock.models import (
JiraAttachment,
JiraComment,
JiraField,
JiraIssue,
JiraPriority,
JiraSprint,
JiraState,
JiraStatusCategory,
JiraWorklog,
)
from pydantic import ValidationError
REPO_ROOT = Path(__file__).resolve().parents[3]
def _issue_with_dates(created: str, updated: str) -> dict:
return {
"id": "10001",
"key": "MOCK-1",
"self": "https://api.atlassian.com/ex/jira/mock/rest/api/3/issue/MOCK-1",
"fields": {
"summary": "Schema check",
"description": None,
"issuetype": {"id": "10001", "name": "Task", "subtask": False},
"project": {"id": "10001", "key": "MOCK", "name": "Mock Project"},
"status": {"id": "10001", "name": "To Do"},
"created": created,
"updated": updated,
},
}
@pytest.mark.parametrize(
"timestamp",
[
"2026-05-08T14:30:00Z",
"2026-05-08T14:30:00.123Z",
"2026-05-08T14:30:00-04:00",
"2026-05-08T14:30:00.123-0400",
],
)
def test_jira_datetime_accepts_rest_api_timestamp_shapes(timestamp: str) -> None:
issue = JiraIssue.model_validate(_issue_with_dates(timestamp, timestamp))
assert issue.fields.created == timestamp
def test_jira_datetime_rejects_naive_timestamp() -> None:
with pytest.raises(ValidationError):
JiraIssue.model_validate(_issue_with_dates("2026-05-08T14:30:00", "2026-05-08T14:30:00Z"))
def test_issue_type_and_priority_names_allow_custom_values() -> None:
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["issuetype"]["name"] = "Customer Escalation"
data["fields"]["priority"] = {"id": "7", "name": "Launch Blocker"}
issue = JiraIssue.model_validate(data)
assert issue.fields.issuetype.name == "Customer Escalation"
assert isinstance(issue.fields.priority, JiraPriority)
assert issue.fields.priority.name == "Launch Blocker"
def test_issue_type_and_priority_names_reject_empty_values() -> None:
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["issuetype"]["name"] = " "
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["priority"] = {"id": "7", "name": ""}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
def test_jira_user_account_type_is_constrained() -> None:
JiraState.model_validate(
{
"currentUserAccountId": "app-user",
"users": {"app-user": {"accountId": "app-user", "accountType": "app", "displayName": "App User"}},
}
)
with pytest.raises(ValidationError):
JiraState.model_validate(
{
"currentUserAccountId": "custom-user",
"users": {
"custom-user": {
"accountId": "custom-user",
"accountType": "external",
"displayName": "Custom User",
}
},
}
)
def test_stable_jira_enums_are_constrained() -> None:
JiraStatusCategory.model_validate({"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"})
JiraSprint.model_validate(
{
"id": 1,
"self": "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1",
"state": "future",
"name": "Sprint 1",
"originBoardId": 1000,
}
)
with pytest.raises(ValidationError):
JiraStatusCategory.model_validate({"id": 2, "key": "todo", "name": "To Do", "colorName": "blue-gray"})
with pytest.raises(ValidationError):
JiraSprint.model_validate(
{
"id": 1,
"self": "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1",
"state": "completed",
"name": "Sprint 1",
"originBoardId": 1000,
}
)
def test_adf_node_types_are_constrained() -> None:
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["description"] = {
"type": "doc",
"version": 1,
"content": [{"type": "unknownBlock", "content": [{"type": "text", "text": "hello"}]}],
}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
def test_state_keys_must_match_entity_ids() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
state = {"issues": {"OTHER-1": issue}}
with pytest.raises(ValidationError, match="issues key"):
JiraState.model_validate(state)
state = {"projects": {"OTHER": {"id": "10001", "key": "MOCK", "name": "Mock Project"}}}
with pytest.raises(ValidationError, match="projects key"):
JiraState.model_validate(state)
state = {"users": {"other-user": {"accountId": "user-1", "displayName": "User 1"}}}
with pytest.raises(ValidationError, match="users key"):
JiraState.model_validate(state)
state = {
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"boards": {"2": {"id": 1, "name": "Mock Board", "type": "scrum", "projectKey": "MOCK"}},
}
with pytest.raises(ValidationError, match="boards key"):
JiraState.model_validate(state)
state = {
"sprints": {
"2": {
"id": 1,
"self": "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1",
"state": "future",
"name": "Sprint 1",
"originBoardId": 1000,
}
}
}
with pytest.raises(ValidationError, match="sprints key"):
JiraState.model_validate(state)
def test_comments_and_worklogs_must_reference_existing_issue_keys() -> None:
comment = {
"id": "1",
"author": {"accountId": "commenter-001", "displayName": "User commenter-001"},
"body": {"type": "doc", "version": 1, "content": []},
"created": "2026-05-08T14:30:00Z",
"updated": "2026-05-08T14:30:00Z",
}
worklog = {
"id": "1",
"author": {"accountId": "worker-001", "displayName": "User worker-001"},
"updateAuthor": {"accountId": "worker-001", "displayName": "User worker-001"},
"created": "2026-05-08T14:30:00Z",
"updated": "2026-05-08T14:30:00Z",
"started": "2026-05-08T14:30:00Z",
"timeSpent": "1h",
"timeSpentSeconds": 3600,
}
with pytest.raises(ValidationError, match="comments key"):
JiraState.model_validate({"comments": {"MOCK-1": [comment]}})
with pytest.raises(ValidationError, match="worklogs key"):
JiraState.model_validate({"worklogs": {"MOCK-1": [worklog]}})
def test_issue_relationships_must_reference_existing_issue_keys() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
state = {
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
}
parent_issue = {
**issue,
"fields": {
**issue["fields"],
"parent": {"id": "10002", "key": "MOCK-2"},
},
}
with pytest.raises(ValidationError, match="parent references missing issue"):
JiraState.model_validate({**state, "issues": {"MOCK-1": parent_issue}})
linked_issue = {
**issue,
"fields": {
**issue["fields"],
"issuelinks": [
{
"id": "1",
"type": {"id": "10001", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
"inwardIssue": {"id": "10002", "key": "MOCK-2"},
}
],
},
}
with pytest.raises(ValidationError, match="inward link references missing issue"):
JiraState.model_validate({**state, "issues": {"MOCK-1": linked_issue}})
def test_issue_projects_must_match_state_projects() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
with pytest.raises(ValidationError, match="project references missing project"):
JiraState.model_validate({"issues": {"MOCK-1": issue}})
with pytest.raises(ValidationError, match="project id"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "99999", "key": "MOCK", "name": "Mock Project"}},
}
)
issue_with_wrong_project = deepcopy(issue)
issue_with_wrong_project["fields"]["project"] = {"id": "10002", "key": "TEST", "name": "Test Project"}
with pytest.raises(ValidationError, match="key prefix"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue_with_wrong_project},
"projects": {"TEST": {"id": "10002", "key": "TEST", "name": "Test Project"}},
}
)
def test_issue_statuses_must_reference_configured_statuses() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
issue["fields"]["status"] = {"id": "99999", "name": "to do"}
statuses = {
"10001": {
"id": "10001",
"name": "To Do",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
}
}
state = JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"To Do": []},
}
)
assert state.issues["MOCK-1"].fields.status.id == "10001"
assert state.issues["MOCK-1"].fields.status.name == "To Do"
assert state.issues["MOCK-1"].fields.status.statusCategory is not None
assert state.issues["MOCK-1"].fields.status.statusCategory.key == "new"
issue_with_missing_status = deepcopy(issue)
issue_with_missing_status["fields"]["status"] = {"id": "10005", "name": "Blocked"}
with pytest.raises(ValidationError, match="issue 'MOCK-1' status references missing status 'Blocked'"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue_with_missing_status},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"To Do": []},
}
)
def test_sample_bundle_jira_state_is_loadable() -> None:
state = json.loads((REPO_ROOT / "fixtures/sample_bundle/services/jira.json").read_text())
JiraState.model_validate(state)
def test_workflows_must_reference_configured_statuses() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
statuses = {
"10001": {
"id": "10001",
"name": "To Do",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
},
"10002": {
"id": "10002",
"name": "In Progress",
"statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress", "colorName": "yellow"},
},
}
state = JiraState.model_validate(
{
"defaultStatusValue": "to do",
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"to do": [{"id": "1", "name": "Start Progress", "to": "in progress"}]},
}
)
assert state.defaultStatusValue == "To Do"
assert list(state.workflow) == ["To Do"]
assert state.workflow["To Do"][0].to == "In Progress"
duplicate_status_names = deepcopy(statuses)
duplicate_status_names["10003"] = {
"id": "10003",
"name": "to do",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
}
with pytest.raises(ValidationError, match="duplicate status name 'to do'"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": duplicate_status_names,
"workflow": {"To Do": []},
}
)
with pytest.raises(ValidationError, match="duplicate workflow entry for status 'To Do'"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"To Do": [], "to do": []},
}
)
with pytest.raises(ValidationError, match="workflow key 'Blocked' references missing status 'Blocked'"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"To Do": [], "Blocked": []},
}
)
with pytest.raises(
ValidationError, match="workflow transition '1' from 'To Do' references missing status 'Blocked'"
):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"statuses": statuses,
"workflow": {"To Do": [{"id": "1", "name": "Start Blocked", "to": "Blocked"}]},
}
)
def test_issue_user_references_must_match_state_users() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
issue["fields"]["assignee"] = {"accountId": "user-1", "displayName": "User 1"}
with pytest.raises(ValidationError, match="assignee references missing user"):
JiraState.model_validate(
{
"users": {},
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
}
)
JiraState.model_validate(
{
"users": {"user-1": {"accountId": "user-1", "displayName": "User 1"}},
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
}
)
def test_legacy_embedded_users_are_migrated_when_users_table_is_missing() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
issue["fields"]["assignee"] = {"accountId": "user-1", "displayName": "User 1"}
state = JiraState.model_validate(
{
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
}
)
assert state.users["user-1"].displayName == "User 1"
def test_embedded_users_and_projects_are_canonicalized_from_state() -> None:
issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
issue["fields"]["project"]["name"] = "Drifted Project"
issue["fields"]["assignee"] = {"accountId": "user-1", "displayName": "Drifted User"}
state = JiraState.model_validate(
{
"users": {"user-1": {"accountId": "user-1", "displayName": "Canonical User"}},
"issues": {"MOCK-1": issue},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Canonical Project"}},
}
)
assert state.issues["MOCK-1"].fields.project.name == "Canonical Project"
assert state.issues["MOCK-1"].fields.assignee is not None
assert state.issues["MOCK-1"].fields.assignee.displayName == "Canonical User"
def test_boards_and_sprints_must_reference_existing_entities() -> None:
with pytest.raises(ValidationError, match="board '1000' references missing project"):
JiraState.model_validate({"boards": {"1000": {"id": 1000, "name": "Mock Board", "projectKey": "MOCK"}}})
with pytest.raises(ValidationError, match="sprint '1' references missing board"):
JiraState.model_validate(
{
"sprints": {
"1": {
"id": 1,
"self": "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1",
"state": "future",
"name": "Sprint 1",
"originBoardId": 1000,
}
}
}
)
with pytest.raises(ValidationError, match="sprint '1' references non-scrum board"):
JiraState.model_validate(
{
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
"boards": {"1000": {"id": 1000, "name": "Mock Kanban Board", "type": "kanban", "projectKey": "MOCK"}},
"sprints": {
"1": {
"id": 1,
"self": "https://api.atlassian.com/ex/jira/mock/rest/agile/1.0/sprint/1",
"state": "future",
"name": "Sprint 1",
"originBoardId": 1000,
}
},
}
)
def test_state_entity_ids_must_be_unique() -> None:
issue_1 = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
issue_2 = deepcopy(issue_1)
issue_2["key"] = "MOCK-2"
with pytest.raises(ValidationError, match="duplicate issue id"):
JiraState.model_validate(
{
"issues": {"MOCK-1": issue_1, "MOCK-2": issue_2},
"projects": {"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"}},
}
)
with pytest.raises(ValidationError, match="duplicate project id"):
JiraState.model_validate(
{
"projects": {
"MOCK": {"id": "10001", "key": "MOCK", "name": "Mock Project"},
"TEST": {"id": "10001", "key": "TEST", "name": "Test Project"},
}
}
)
def test_issue_project_and_numeric_ids_are_constrained() -> None:
valid_issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
invalid_issue = {**valid_issue, "key": "mock-1"}
with pytest.raises(ValidationError):
JiraIssue.model_validate(invalid_issue)
invalid_issue = {**valid_issue, "id": "abc"}
with pytest.raises(ValidationError):
JiraIssue.model_validate(invalid_issue)
invalid_issue = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
invalid_issue["fields"]["project"]["key"] = "mock"
with pytest.raises(ValidationError):
JiraIssue.model_validate(invalid_issue)
with pytest.raises(ValidationError):
JiraComment.model_validate(
{
"id": "",
"author": {"accountId": "commenter-001", "displayName": "User commenter-001"},
"body": {"type": "doc", "version": 1, "content": []},
"created": "2026-05-08T14:30:00Z",
"updated": "2026-05-08T14:30:00Z",
}
)
with pytest.raises(ValidationError):
JiraWorklog.model_validate(
{
"id": "worklog-1",
"author": {"accountId": "worker-001", "displayName": "User worker-001"},
"updateAuthor": {"accountId": "worker-001", "displayName": "User worker-001"},
"created": "2026-05-08T14:30:00Z",
"updated": "2026-05-08T14:30:00Z",
"started": "2026-05-08T14:30:00Z",
"timeSpent": "1h",
"timeSpentSeconds": 3600,
}
)
with pytest.raises(ValidationError):
JiraAttachment.model_validate(
{
"id": "file-1",
"filename": "note.txt",
"author": {"accountId": "uploader-001", "displayName": "User uploader-001"},
"created": "2026-05-08T14:30:00Z",
"size": 4,
"mimeType": "text/plain",
"content": "aGVsbG8=",
}
)
def test_issue_nested_objects_are_typed() -> None:
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["parent"] = {
"id": "10000",
"key": "mock-1",
"fields": {
"summary": "Parent",
"status": {"id": "10001", "name": "To Do"},
"issuetype": {"id": "10000", "name": "Epic"},
},
}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["watches"] = {
"watchCount": 2,
"watchers": [{"accountId": "alice", "displayName": "Alice"}],
}
with pytest.raises(ValidationError, match="watchCount"):
JiraIssue.model_validate(data)
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["timetracking"] = {"timeSpent": "1h", "timeSpentSeconds": -1}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
def test_issue_expanded_objects_are_typed() -> None:
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["fields"]["comment"] = {
"comments": [
{
"id": "1",
"author": {"accountId": "commenter-001", "displayName": "User commenter-001"},
"body": {"not": "adf"},
"created": "2026-05-08T14:30:00Z",
"updated": "2026-05-08T14:30:00Z",
}
],
"maxResults": 1,
"total": 1,
"startAt": 0,
}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["changelog"] = {
"histories": [
{
"id": "history-1",
"created": "2026-05-08T14:30:00Z",
"items": [{"field": "status", "fromString": "To Do", "toString": "Done"}],
}
]
}
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
data = _issue_with_dates("2026-05-08T14:30:00Z", "2026-05-08T14:30:00Z")
data["transitions"] = [{"id": "start", "name": "Start Progress", "to": {"id": "10002", "name": "In Progress"}}]
with pytest.raises(ValidationError):
JiraIssue.model_validate(data)
def test_field_schema_is_typed() -> None:
field = JiraField.model_validate(
{
"id": "customfield_10001",
"key": "customfield_10001",
"name": "Story Points",
"custom": True,
"schema": {"type": "number", "customId": 10001},
}
)
assert field.schema_ is not None
assert field.schema_.type == "number"
with pytest.raises(ValidationError):
JiraField.model_validate(
{
"id": "customfield_10001",
"key": "customfield_10001",
"name": "Story Points",
"custom": True,
"schema": {"customId": -1},
}
)
@@ -0,0 +1,229 @@
from __future__ import annotations
import pytest
from jira_mock.server import (
add_comment,
add_worklog,
create_board,
create_issue,
create_sprint,
export_state,
get_boards,
get_sprint_issues,
get_sprints_from_board,
get_worklogs,
import_state,
update_estimate,
update_sprint,
)
from jira_mock.state import init_state, set_snapshot_paths, write_snapshots
from jira_mock.viewer import create_app
from starlette.testclient import TestClient
@pytest.mark.asyncio
async def test_sprints_and_sprint_issues() -> None:
await create_issue("MOCK", "Sprint item", "Story", additional_fields='{"labels":["seed"]}')
boards = await get_boards(project_key="MOCK")
assert boards["total"] == 1
assert boards["values"][0]["id"] == 1000
assert boards["values"][0]["projectKey"] == "MOCK"
sprint = await create_sprint("1000", "Sprint 1", "2026-01-01T00:00:00Z", "2026-01-15T00:00:00Z", goal="Ship")
state = await export_state()
state["issues"]["MOCK-1"]["fields"]["customfield_10002"] = sprint["id"]
await import_state(state)
assert (await get_sprints_from_board("1000"))["values"][0]["name"] == "Sprint 1"
assert (await get_sprint_issues(str(sprint["id"])))["total"] == 1
filtered_issue = (await get_sprint_issues(str(sprint["id"]), fields="summary"))["issues"][0]
assert filtered_issue["fields"] == {"summary": "Sprint item"}
assert (await update_sprint(str(sprint["id"]), state="closed"))["state"] == "closed"
@pytest.mark.asyncio
async def test_sprints_are_scoped_to_boards() -> None:
mock_sprint = await create_sprint("1000", "Mock sprint", "2026-01-01T00:00:00Z", "2026-01-15T00:00:00Z")
test_sprint = await create_sprint("1001", "Test sprint", "2026-02-01T00:00:00Z", "2026-02-15T00:00:00Z")
assert [sprint["id"] for sprint in (await get_sprints_from_board("1000"))["values"]] == [mock_sprint["id"]]
assert [sprint["id"] for sprint in (await get_sprints_from_board("1001"))["values"]] == [test_sprint["id"]]
@pytest.mark.asyncio
async def test_admin_can_create_boards() -> None:
with pytest.raises(PermissionError):
await create_board("MOCK", "Unauthorized Board")
state = await export_state()
state["is_admin"] = True
await import_state(state)
board = await create_board("MOCK", "Operations Scrum Board")
assert board["id"] == 1002
assert board["type"] == "scrum"
assert board["projectKey"] == "MOCK"
assert board["filterJql"] == "project = MOCK"
sprint = await create_sprint(str(board["id"]), "Operations sprint", "2026-03-01T00:00:00Z", "2026-03-15T00:00:00Z")
assert sprint["originBoardId"] == board["id"]
@pytest.mark.asyncio
async def test_create_board_recovers_seeded_board_counter() -> None:
state = await export_state()
state["is_admin"] = True
state["boards"]["2000"] = {
"id": 2000,
"name": "Seeded Scrum Board",
"type": "scrum",
"projectKey": "MOCK",
"filterJql": "project = MOCK",
}
await import_state(state)
assert (await create_board("MOCK", "Next Scrum Board"))["id"] == 2001
@pytest.mark.asyncio
async def test_sprints_require_existing_scrum_board() -> None:
with pytest.raises(ValueError, match="Board 9999 not found"):
await create_sprint("9999", "Missing board", "2026-01-01T00:00:00Z", "2026-01-15T00:00:00Z")
state = await export_state()
state["boards"]["2000"] = {
"id": 2000,
"name": "Mock Kanban Board",
"type": "kanban",
"projectKey": "MOCK",
"filterJql": "project = MOCK",
}
await import_state(state)
with pytest.raises(ValueError, match="Sprints can only be created for Scrum boards"):
await create_sprint("2000", "Kanban sprint", "2026-01-01T00:00:00Z", "2026-01-15T00:00:00Z")
@pytest.mark.asyncio
async def test_worklogs_update_timetracking() -> None:
await create_issue("MOCK", "Timed work", "Task")
await update_estimate("MOCK-1", "1d")
worklog = await add_worklog(
"MOCK-1",
"2h 30m",
comment="Investigated",
started="2026-05-08T14:30:00Z",
)
assert worklog["timeSpentSeconds"] == 9000
assert worklog["started"] == "2026-05-08T14:30:00Z"
summary = await get_worklogs("MOCK-1")
assert summary["totalTimeSpentSeconds"] == 9000
assert summary["timetracking"]["remainingEstimate"] == "5h 30m"
@pytest.mark.asyncio
async def test_state_round_trips() -> None:
await create_issue("MOCK", "Round trip", "Task")
state = await export_state()
state["issues"]["MOCK-1"]["fields"]["summary"] = "Changed outside tool"
assert (await import_state(state)) == {"ok": True}
assert (await export_state())["issues"]["MOCK-1"]["fields"]["summary"] == "Changed outside tool"
def test_snapshot_paths_support_partial_updates(tmp_path) -> None:
bundle_path = tmp_path / "bundle.json"
first_final_path = tmp_path / "first-final.json"
second_final_path = tmp_path / "second-final.json"
try:
set_snapshot_paths(final_path=first_final_path, bundle_state_path=bundle_path)
write_snapshots()
assert bundle_path.exists()
assert first_final_path.exists()
bundle_path.unlink()
set_snapshot_paths(final_path=second_final_path)
write_snapshots()
assert bundle_path.exists()
assert second_final_path.exists()
finally:
set_snapshot_paths(final_path=None, bundle_state_path=None)
def test_init_state_writes_initial_json_without_final_snapshot(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OUTPUTDIR", str(tmp_path))
try:
init_state()
assert (tmp_path / "initial.json").exists()
assert not (tmp_path / "final.json").exists()
finally:
set_snapshot_paths(final_path=None, bundle_state_path=None)
@pytest.mark.asyncio
async def test_viewer_api_smoke(monkeypatch) -> None:
await create_issue("MOCK", "Viewer issue", "Task", components="API,Frontend")
await add_comment("MOCK-1", "Viewer comment")
client = TestClient(create_app())
home = client.get("/")
assert home.status_code == 200
assert "list-view" in home.text
assert "kanban-view" in home.text
assert "detail-panel" in home.text
issues = client.get("/api/issues")
assert issues.status_code == 200
assert issues.json()["issues"][0]["key"] == "MOCK-1"
assert issues.json()["issues"][0]["status"] == "To Do"
assert client.get("/api/issues", params={"project": "MOCK"}).json()["total"] == 1
assert client.get("/api/issues", params={"type": "Bug"}).json()["total"] == 0
assert client.get("/api/projects").json()["projects"][0]["issueCount"] == 1
detail = client.get("/api/issues/MOCK-1").json()
assert detail["issue"]["components"] == ["API", "Frontend"]
assert detail["comments"][0]["body"] == "Viewer comment"
monkeypatch.setenv("MCP_PROXY_TOKEN", "secret")
authed_client = TestClient(create_app())
assert authed_client.get("/api/issues").status_code == 403
assert authed_client.get("/api/issues", headers={"x-proxy-token": "secret"}).status_code == 200
@pytest.mark.asyncio
async def test_viewer_shows_issue_attachments() -> None:
import base64
from jira_mock.models import JiraAttachment
from jira_mock.state import get_state
await create_issue("MOCK", "Issue with attachment", "Task")
state = get_state()
author = next(iter(state.users.values()))
state.issues["MOCK-1"].fields.attachment = [
JiraAttachment(
id="1",
filename="report.txt",
author=author,
created="2026-01-01T00:00:00Z",
size=11,
mimeType="text/plain",
content=base64.b64encode(b"hello world").decode(),
)
]
client = TestClient(create_app())
# Issue list signals attachment presence.
assert client.get("/api/issues").json()["issues"][0]["attachmentCount"] == 1
# Issue detail exposes attachment metadata, never the stored bytes.
detail = client.get("/api/issues/MOCK-1").json()["issue"]
assert len(detail["attachments"]) == 1
attachment = detail["attachments"][0]
assert attachment["filename"] == "report.txt"
assert attachment["mimeType"] == "text/plain"
assert attachment["size"] == 11
assert "content" not in attachment
@@ -0,0 +1,204 @@
from __future__ import annotations
import base64
import pytest
from jira_mock.server import (
add_attachment,
add_comment,
add_watcher,
create_issue,
create_status,
create_user,
export_state,
get_attachments,
get_issue,
get_transitions,
get_watchers,
import_state,
remove_watcher,
transition_issue,
upsert_workflow_transition,
)
@pytest.mark.asyncio
async def test_transitions_follow_default_workflow() -> None:
await create_issue("MOCK", "Workflow", "Task")
transitions = (await get_transitions("MOCK-1"))["transitions"]
assert transitions[0]["id"] == "1"
issue = await transition_issue("MOCK-1", "1")
assert issue["fields"]["status"]["name"] == "In Progress"
with pytest.raises(ValueError, match="not available"):
await transition_issue("MOCK-1", "1")
@pytest.mark.asyncio
async def test_empty_statuses_and_workflow_inherit_defaults() -> None:
state = await export_state()
state["statuses"] = {}
state["workflow"] = {}
state.pop("defaultStatusValue", None)
await import_state(state)
exported = await export_state()
assert exported["defaultStatusValue"] == "To Do"
assert exported["statuses"]["10001"]["name"] == "To Do"
assert "To Do" in exported["workflow"]
created = await create_issue("MOCK", "Default status after empty maps", "Task")
assert (await get_issue(created["key"], fields="status"))["fields"]["status"]["name"] == "To Do"
@pytest.mark.asyncio
async def test_empty_default_status_value_inherits_default() -> None:
state = await export_state()
state["defaultStatusValue"] = ""
await import_state(state)
assert (await export_state())["defaultStatusValue"] == "To Do"
@pytest.mark.asyncio
async def test_create_issue_uses_configured_default_status_value() -> None:
state = await export_state()
state["defaultStatusValue"] = "Backlog"
state["statuses"] = {
"20001": {
"id": "20001",
"name": "Backlog",
"description": "Ready for triage",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
},
"20002": {
"id": "20002",
"name": "Selected",
"description": "Ready to start",
"statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress", "colorName": "yellow"},
},
}
state["workflow"] = {"Backlog": [{"id": "21", "name": "Select for Work", "to": "Selected"}], "Selected": []}
await import_state(state)
created = await create_issue("MOCK", "Starts in custom default", "Task")
assert (await get_issue(created["key"], fields="status"))["fields"]["status"]["name"] == "Backlog"
transitions = (await get_transitions(created["key"]))["transitions"]
assert transitions == [
{
"id": "21",
"name": "Select for Work",
"to": {
"id": "20002",
"name": "Selected",
"description": "Ready to start",
"iconUrl": None,
"statusCategory": {
"id": 4,
"key": "indeterminate",
"name": "In Progress",
"colorName": "yellow",
},
},
"hasScreen": False,
"isGlobal": False,
"isInitial": False,
"isAvailable": True,
"isConditional": False,
"isLooped": False,
}
]
@pytest.mark.asyncio
async def test_default_status_value_must_reference_status_and_workflow() -> None:
state = await export_state()
state["defaultStatusValue"] = "Backlog"
with pytest.raises(ValueError, match="defaultStatusValue 'Backlog' does not reference a configured status"):
await import_state(state)
state["statuses"]["20001"] = {
"id": "20001",
"name": "Backlog",
"description": "Ready for triage",
"statusCategory": {"id": 2, "key": "new", "name": "To Do", "colorName": "blue-gray"},
}
with pytest.raises(ValueError, match="defaultStatusValue 'Backlog' does not have a workflow entry"):
await import_state(state)
@pytest.mark.asyncio
async def test_admin_can_create_custom_status_and_workflow_transition() -> None:
state = await export_state()
state["is_admin"] = True
await import_state(state)
await create_issue("MOCK", "Blocked work", "Task")
status = await create_status("10005", "Blocked", "indeterminate", description="Work is blocked")
transition = await upsert_workflow_transition("To Do", "8", "Mark Blocked", "Blocked")
assert status["name"] == "Blocked"
assert transition == {"id": "8", "name": "Mark Blocked", "to": "Blocked"}
assert any(item["id"] == "8" for item in (await get_transitions("MOCK-1"))["transitions"])
assert (await transition_issue("MOCK-1", "8"))["fields"]["status"]["name"] == "Blocked"
@pytest.mark.asyncio
async def test_workflow_transition_upsert_canonicalizes_status_names() -> None:
state = await export_state()
state["is_admin"] = True
await import_state(state)
await create_issue("MOCK", "Blocked work", "Task")
await create_status("10005", "Blocked", "indeterminate", description="Work is blocked")
transition = await upsert_workflow_transition("to do", "8", "Mark Blocked", "blocked")
assert transition == {"id": "8", "name": "Mark Blocked", "to": "Blocked"}
exported = await export_state()
assert "to do" not in exported["workflow"]
assert exported["workflow"]["To Do"][-1] == {"id": "8", "name": "Mark Blocked", "to": "Blocked"}
assert any(item["id"] == "8" for item in (await get_transitions("MOCK-1"))["transitions"])
assert (await transition_issue("MOCK-1", "8"))["fields"]["status"]["name"] == "Blocked"
@pytest.mark.asyncio
async def test_non_admin_cannot_configure_statuses_or_workflows() -> None:
with pytest.raises(PermissionError, match="Admin privileges"):
await create_status("10005", "Blocked", "indeterminate")
with pytest.raises(PermissionError, match="Admin privileges"):
await upsert_workflow_transition("To Do", "8", "Mark Blocked", "Blocked")
@pytest.mark.asyncio
async def test_comments_watchers_and_attachments() -> None:
await create_issue("MOCK", "Collaboration", "Task")
comment = await add_comment("MOCK-1", "Looks good")
assert comment["body"]["content"][0]["content"][0]["text"] == "Looks good"
assert (await get_issue("MOCK-1", fields="comment"))["fields"]["comment"]["total"] == 1
await add_comment("MOCK-1", "Second")
assert "comment" not in (await export_state())["issues"]["MOCK-1"]["fields"]
assert (await get_issue("MOCK-1", fields="comment"))["fields"]["comment"]["total"] == 2
state = await export_state()
state["is_admin"] = True
await import_state(state)
await create_user("alice", "Alice")
assert (await add_watcher("MOCK-1", "alice"))["message"] == "Added watcher alice to MOCK-1"
assert (await add_watcher("MOCK-1", "alice"))["message"] == "User alice is already watching MOCK-1"
assert (await get_watchers("MOCK-1"))["watchCount"] == 1
assert (await remove_watcher("MOCK-1", "alice"))["message"] == "Removed watcher alice from MOCK-1"
payload = base64.b64encode(b"hello").decode()
attachment = await add_attachment("MOCK-1", "note.txt", payload)
assert attachment["mimeType"] == "text/plain"
attachments = await get_attachments("MOCK-1")
assert attachments["total"] == 1
assert "content" not in attachments["attachments"][0]
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""
Utilities for Jira MCP state management.
Used by preprocess and evaluation scripts.
"""
from pathlib import Path
def get_jira_state_path(agent_workspace: str | Path) -> Path:
"""
Get the Jira state file path for a given workspace.
Stores in external_services/ directory NEXT TO the workspace:
- If workspace is at /workspace/dumps/workspace, stores at /workspace/dumps/external_services/
- Outside the agent workspace so it can't be read directly via filesystem MCP
- Inside the dumps mount so it persists to the host
- Path is deterministic and can be computed by preprocess, MCP, and evaluation
"""
workspace_path = Path(agent_workspace)
# Go up one level from workspace and create external_services directory
external_services_dir = workspace_path.parent / "external_services"
return external_services_dir / "jira_state.json"