mann / server.py
gcharanteja
nigg
631881d
Raw
History Blame Contribute Delete
16.7 kB
"""
Jira Sprint Management REST API
Built with FastAPI - allows other applications to interact with Jira programmatically
"""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime, timedelta, timezone
import requests
from requests.auth import HTTPBasicAuth
import json
import os
from dotenv import load_dotenv
load_dotenv()
# ================= CONFIG =================
BASE_URL = os.environ.get("JIRA_BASE_URL", "")
EMAIL = os.environ.get("JIRA_EMAIL", "")
API_TOKEN = os.environ.get("JIRA_API_TOKEN", "")
PROJECT_KEY = os.environ.get("JIRA_PROJECT_KEY", "SCRUM")
BOARD_ID = int(os.environ.get("JIRA_BOARD_ID", "1"))
if not BASE_URL:
raise ValueError("Missing required environment variable: JIRA_BASE_URL")
if not EMAIL:
raise ValueError("Missing required environment variable: JIRA_EMAIL")
if not API_TOKEN:
raise ValueError("Missing required environment variable: JIRA_API_TOKEN")
auth = HTTPBasicAuth(EMAIL, API_TOKEN)
headers = {
"Accept": "application/json",
"Content-Type": "application/json"
}
# ================= FASTAPI APP =================
app = FastAPI(
title="Jira Sprint Management API",
description="REST API for managing Jira sprints, stories, and issues",
version="1.0.0"
)
# ================= REQUEST/RESPONSE MODELS =================
class StoryCreate(BaseModel):
name: str = Field(..., description="Story name/summary")
description: Optional[str] = Field("", description="Story description")
class SprintAddIssues(BaseModel):
issue_keys: list[str] = Field(..., description="List of issue keys to add to sprint")
class IssueTransition(BaseModel):
status: str = Field(..., description="Status: '1'=To Do, '2'=In Progress, '3'=Testing, '4'=Done")
comment: Optional[str] = Field("", description="Reason/comment for the status change")
class SprintRollover(BaseModel):
new_sprint_name: str = Field(..., description="Name for the new sprint")
add_backlog_to_new_sprint: bool = Field(
False,
description="Whether to add remaining backlog items to the new sprint"
)
# ================= HELPER FUNCTIONS =================
# Map simple status codes to Jira transition IDs
STATUS_MAP = {
"1": {"id": "11", "name": "To Do"},
"2": {"id": "21", "name": "In Progress"},
"3": {"id": "31", "name": "Testing"},
"4": {"id": "41", "name": "Done"}
}
def get_transition_id(status_code):
"""Convert simple status code (1,2,3,4) to Jira transition ID"""
if status_code not in STATUS_MAP:
raise ValueError(f"Invalid status code: {status_code}. Use 1=To Do, 2=In Progress, 3=Testing, 4=Done")
return STATUS_MAP[status_code]
def get_next_saturday_friday():
today = datetime.now(timezone.utc)
days_until_sat = (5 - today.weekday()) % 7
if days_until_sat == 0:
days_until_sat = 7
start = today + timedelta(days=days_until_sat)
end = start + timedelta(days=6)
return (
start.strftime("%Y-%m-%dT%H:%M:%S.000+0000"),
end.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
)
def parse_description(desc_data):
"""Parse Jira description JSON to plain text"""
if not desc_data or "content" not in desc_data:
return ""
text = ""
for block in desc_data.get("content", []):
if "content" in block:
for item in block["content"]:
if "text" in item:
text += item["text"]
return text
# ================= API ENDPOINTS =================
@app.get("/")
def root():
"""API health check"""
return {
"status": "ok",
"message": "Jira Sprint Management API is running",
"version": "1.0.0"
}
@app.get("/api/backlog")
def get_backlog():
"""
Get all backlog issues (issues not in any sprint and not done)
Returns: List of issues with id, name, description, status
"""
try:
jql = f'project={PROJECT_KEY} AND sprint IS EMPTY AND statusCategory != Done'
url = f"{BASE_URL}/rest/api/3/search/jql"
params = {
"jql": jql,
"fields": "summary,status,description",
"maxResults": 100
}
res = requests.get(url, headers=headers, auth=auth, params=params)
res.raise_for_status()
issues = res.json()["issues"]
return {
"success": True,
"count": len(issues),
"issues": [
{
"id": issue["key"],
"name": issue["fields"]["summary"],
"description": parse_description(issue["fields"].get("description")),
"status": issue["fields"]["status"]["name"]
}
for issue in issues
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch backlog: {str(e)}")
@app.post("/api/story")
def create_story(story: StoryCreate):
"""
Create a new story in Jira
Returns: Created issue details with id and key
"""
try:
url = f"{BASE_URL}/rest/api/3/issue"
payload = {
"fields": {
"project": {"key": PROJECT_KEY},
"summary": story.name,
"issuetype": {"name": "Story"},
"description": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": story.description if story.description else "No description provided"
}
]
}
]
}
}
}
res = requests.post(url, headers=headers, auth=auth, data=json.dumps(payload))
res.raise_for_status()
data = res.json()
return {
"success": True,
"message": "Story created successfully",
"issue": {
"id": data["id"],
"key": data["key"],
"name": story.name,
"description": story.description
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create story: {str(e)}")
@app.get("/api/sprint/active")
def get_active_sprint():
"""
Get the current active sprint and its issues
Returns: Sprint details with all issues and their statuses
"""
try:
url = f"{BASE_URL}/rest/agile/1.0/board/{BOARD_ID}/sprint?state=active"
res = requests.get(url, headers=headers, auth=auth)
res.raise_for_status()
sprints = res.json()["values"]
if not sprints:
return {
"success": True,
"active_sprint": None,
"message": "No active sprint found"
}
sprint = sprints[0]
sprint_id = sprint["id"]
# Get sprint issues
issues_url = f"{BASE_URL}/rest/agile/1.0/sprint/{sprint_id}/issue"
issues_res = requests.get(issues_url, headers=headers, auth=auth)
issues_res.raise_for_status()
issues = issues_res.json()["issues"]
return {
"success": True,
"active_sprint": {
"id": sprint["id"],
"name": sprint["name"],
"state": sprint["state"],
"start_date": sprint["startDate"],
"end_date": sprint["endDate"],
"issues": [
{
"id": issue["key"],
"name": issue["fields"]["summary"],
"status": issue["fields"]["status"]["name"],
"status_category": issue["fields"]["status"]["statusCategory"]["name"]
}
for issue in issues
],
"issue_count": len(issues)
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch active sprint: {str(e)}")
@app.post("/api/sprint/{sprint_id}/add-issues")
def add_issues_to_sprint(sprint_id: int, body: SprintAddIssues):
"""
Add issues to a sprint
Returns: Success message with count of added issues
"""
try:
url = f"{BASE_URL}/rest/agile/1.0/sprint/{sprint_id}/issue"
payload = {"issues": body.issue_keys}
res = requests.post(url, headers=headers, auth=auth, data=json.dumps(payload))
res.raise_for_status()
return {
"success": True,
"message": f"Added {len(body.issue_keys)} issues to sprint",
"added_issues": body.issue_keys,
"sprint_id": sprint_id
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to add issues to sprint: {str(e)}")
@app.post("/api/issue/{issue_key}/transition")
def transition_issue(issue_key: str, body: IssueTransition):
"""
Transition an issue to a new status with an optional comment/reason
Status codes: 1=To Do, 2=In Progress, 3=Testing, 4=Done
Returns: Success message
"""
try:
# Get the Jira transition ID from simple status code
transition = get_transition_id(body.status)
transition_id = transition["id"]
status_name = transition["name"]
# Step 1: Perform the transition
url = f"{BASE_URL}/rest/api/3/issue/{issue_key}/transitions"
payload = {"transition": {"id": transition_id}}
res = requests.post(url, headers=headers, auth=auth, data=json.dumps(payload))
if res.status_code != 204:
raise HTTPException(
status_code=400,
detail=f"Transition failed: {res.text}"
)
# Step 2: Add comment separately (if provided)
comment_added = None
if body.comment:
comment_url = f"{BASE_URL}/rest/api/3/issue/{issue_key}/comment"
comment_payload = {
"body": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{"type": "text", "text": body.comment}
]
}
]
}
}
comment_res = requests.post(comment_url, headers=headers, auth=auth, data=json.dumps(comment_payload))
if comment_res.status_code == 201:
comment_added = body.comment
else:
# Log but don't fail - transition already succeeded
print(f"Warning: Comment not added for {issue_key}: {comment_res.text}")
return {
"success": True,
"message": f"Issue {issue_key} moved to {status_name}",
"issue_key": issue_key,
"status": status_name,
"status_code": body.status,
"comment_added": comment_added
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to transition issue: {str(e)}")
@app.post("/api/sprint/rollover")
def sprint_rollover(body: SprintRollover):
"""
End current sprint and start a new one
- Closes current sprint
- Carries forward unfinished issues
- Creates and starts new sprint
- Optionally adds backlog items to new sprint
Returns: Details of the new sprint and carried forward issues
"""
try:
# Get active sprint
url = f"{BASE_URL}/rest/agile/1.0/board/{BOARD_ID}/sprint?state=active"
res = requests.get(url, headers=headers, auth=auth)
res.raise_for_status()
sprints = res.json()["values"]
if not sprints:
raise HTTPException(status_code=400, detail="No active sprint to close")
active_sprint = sprints[0]
# Get sprint issues to find unfinished ones
issues_url = f"{BASE_URL}/rest/agile/1.0/sprint/{active_sprint['id']}/issue"
issues_res = requests.get(issues_url, headers=headers, auth=auth)
issues_res.raise_for_status()
sprint_issues = issues_res.json()["issues"]
# Find unfinished issues
unfinished = [
issue["key"]
for issue in sprint_issues
if issue["fields"]["status"]["statusCategory"]["name"] != "Done"
]
# Close current sprint
close_url = f"{BASE_URL}/rest/agile/1.0/sprint/{active_sprint['id']}"
close_payload = {
"state": "closed",
"name": active_sprint["name"],
"startDate": active_sprint["startDate"],
"endDate": active_sprint["endDate"]
}
close_res = requests.put(close_url, headers=headers, auth=auth, data=json.dumps(close_payload))
if close_res.status_code != 200:
raise HTTPException(status_code=400, detail=f"Failed to close sprint: {close_res.text}")
# Create new sprint
start, end = get_next_saturday_friday()
create_url = f"{BASE_URL}/rest/agile/1.0/sprint"
create_payload = {
"name": body.new_sprint_name,
"originBoardId": BOARD_ID,
"startDate": start,
"endDate": end
}
create_res = requests.post(create_url, headers=headers, auth=auth, data=json.dumps(create_payload))
create_res.raise_for_status()
new_sprint = create_res.json()
# Start new sprint
start_url = f"{BASE_URL}/rest/agile/1.0/sprint/{new_sprint['id']}"
start_payload = {
"state": "active",
"startDate": start,
"endDate": end,
"name": body.new_sprint_name
}
start_res = requests.put(start_url, headers=headers, auth=auth, data=json.dumps(start_payload))
start_res.raise_for_status()
# Add carry-forward issues
if unfinished:
add_url = f"{BASE_URL}/rest/agile/1.0/sprint/{new_sprint['id']}/issue"
add_payload = {"issues": unfinished}
add_res = requests.post(add_url, headers=headers, auth=auth, data=json.dumps(add_payload))
add_res.raise_for_status()
# Optionally add backlog
added_backlog = []
if body.add_backlog_to_new_sprint:
backlog_jql = f'project={PROJECT_KEY} AND sprint IS EMPTY AND statusCategory != Done'
backlog_url = f"{BASE_URL}/rest/api/3/search/jql"
backlog_params = {"jql": backlog_jql, "maxResults": 100}
backlog_res = requests.get(backlog_url, headers=headers, auth=auth, params=backlog_params)
backlog_res.raise_for_status()
backlog_issues = backlog_res.json()["issues"]
backlog_keys = [issue["key"] for issue in backlog_issues]
if backlog_keys:
add_backlog_url = f"{BASE_URL}/rest/agile/1.0/sprint/{new_sprint['id']}/issue"
add_backlog_payload = {"issues": backlog_keys}
add_backlog_res = requests.post(add_backlog_url, headers=headers, auth=auth, data=json.dumps(add_backlog_payload))
add_backlog_res.raise_for_status()
added_backlog = backlog_keys
return {
"success": True,
"message": "Sprint rolled over successfully",
"old_sprint": {
"id": active_sprint["id"],
"name": active_sprint["name"],
"status": "closed"
},
"new_sprint": {
"id": new_sprint["id"],
"name": body.new_sprint_name,
"start_date": start,
"end_date": end,
"status": "active"
},
"carried_forward": unfinished,
"carried_forward_count": len(unfinished),
"added_from_backlog": added_backlog,
"backlog_count": len(added_backlog)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to rollover sprint: {str(e)}")
# ================= RUN =================
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)