291 lines
10 KiB
Python
291 lines
10 KiB
Python
#!/user/bin/env python3
|
|
"""
|
|
JIRA SPRINT EXPORTER - MAIN.PY
|
|
- ENTRY POINT OF APPLICATION
|
|
|
|
VERSION: 1.0
|
|
|
|
AUTHOR: D. RICE 18/03/2026
|
|
© 2026 ARRIVE
|
|
"""
|
|
|
|
# Imports
|
|
import sys
|
|
from PySide6.QtWidgets import (QApplication, QMainWindow, QTreeWidgetItem, QMessageBox)
|
|
from PySide6.QtGui import QIcon
|
|
import requests
|
|
from requests.auth import HTTPBasicAuth
|
|
import re
|
|
import csv
|
|
import os
|
|
|
|
from ui_mainwindow import Ui_MainWindow
|
|
|
|
# --- CONFIGURATION ---
|
|
JIRA_DOMAIN = "flowbird.atlassian.net"
|
|
EMAIL = "david.rice@arrive.com"
|
|
API_TOKEN = "ATATT3xFfGF0qWIGGGsvwb0oBnekYh88S8jr8XKbwvRkaoFWwq7cPGS2S5gzZkG-o_JXoDuYR5hOSGiL4GIj5XfTfm05mq313yxmkr8DZqVXDdbgwt8HNcxjLPmcWi9cQDy9wJ-Rc17uXIToYZSvZBCHyWiNZQhh5WlTkTwl0yjgGd-x_F-tOn8=F97E27B2"
|
|
BOARD_ID = 3417 # Replace with your board ID
|
|
|
|
# Main window class
|
|
class MainWindow(QMainWindow):
|
|
def __init__(self):
|
|
# Initialise main window
|
|
super(MainWindow, self).__init__()
|
|
|
|
# Finish main window initial setup
|
|
self.ui = Ui_MainWindow()
|
|
self.ui.setupUi(self)
|
|
|
|
# Window Setup
|
|
self.setFixedSize(1024, 768)
|
|
|
|
# Add UI Logic
|
|
self.ui.connButton.setCheckable(True)
|
|
self.ui.connButton.clicked.connect(self.conn_button_press)
|
|
|
|
# Fetch Data from JIRA
|
|
sprints = self.fetch_jira_sprints()
|
|
|
|
# Populate Tree Widget
|
|
self.populate_tree(sprints)
|
|
|
|
def fetch_jira_sprints(self):
|
|
auth = HTTPBasicAuth(EMAIL, API_TOKEN)
|
|
headers = {"Accept": "application/json"}
|
|
sprints_url = f"https://{JIRA_DOMAIN}/rest/agile/1.0/board/{BOARD_ID}/sprint"
|
|
|
|
all_sprints = []
|
|
start_at = 0
|
|
max_results = 50 # Jira default limit per request
|
|
|
|
print("Fetching Sprints from Jira...")
|
|
try:
|
|
while True:
|
|
params = {"startAt": start_at, "maxResults": max_results}
|
|
resp = requests.get(sprints_url, headers=headers, auth=auth, params=params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
all_sprints.extend(data.get("values", []))
|
|
|
|
if data.get("isLast", True):
|
|
break
|
|
|
|
start_at += max_results
|
|
|
|
print(f"FOUND {len(all_sprints)} SPRINTS")
|
|
return all_sprints
|
|
except Exception as e:
|
|
print(f"Error fetching Jira data: {e}")
|
|
return []
|
|
|
|
def populate_tree(self, sprints):
|
|
"""Sorts sprints numerically and populates the Tree Widget."""
|
|
year_parents = {}
|
|
|
|
# Sort the sprints list before processing
|
|
# This regex looks for 'Sprint' followed by a number and converts it to an integer for sorting
|
|
def get_sprint_number(s):
|
|
match = re.search(r"Sprint\s(\d+)", s.get("name", ""))
|
|
return int(match.group(1)) if match else 0
|
|
|
|
sorted_sprints = sorted(sprints, key=get_sprint_number)
|
|
|
|
# Process the sorted list
|
|
pattern = r"(.*)\s(W\d{2})_(W\d{2})_(\d{4})"
|
|
|
|
for sprint in sorted_sprints:
|
|
s_name = sprint.get("name", "Unknown Sprint")
|
|
s_id = sprint.get("id", "N/A")
|
|
|
|
match = re.search(pattern, s_name)
|
|
|
|
if match:
|
|
prefix, w1, w2, year = match.groups()
|
|
|
|
if year not in year_parents:
|
|
parent = QTreeWidgetItem(self.ui.tree)
|
|
parent.setText(0, year)
|
|
year_parents[year] = parent
|
|
|
|
cleaned_name = f"{prefix.strip()} {w1}-{w2} (ID: {s_id})"
|
|
child = QTreeWidgetItem(year_parents[year])
|
|
child.setText(0, cleaned_name)
|
|
else:
|
|
# Fallback for "HW Sprint 1" which might be missing the Wxx_Wxx part
|
|
fallback_key = "Other"
|
|
if fallback_key not in year_parents:
|
|
parent = QTreeWidgetItem(self.ui.tree)
|
|
parent.setText(0, fallback_key)
|
|
year_parents[fallback_key] = parent
|
|
|
|
child = QTreeWidgetItem(year_parents[fallback_key])
|
|
child.setText(0, f"{s_name} (ID: {s_id})")
|
|
|
|
def conn_button_press(self):
|
|
# Get the currently selected item in the tree
|
|
selected_items = self.ui.tree.selectedItems()
|
|
|
|
msg = QMessageBox()
|
|
msg.setIcon(QMessageBox.Icon.Warning)
|
|
|
|
# Get the absolute path of the directory where the program resides
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
ico_path = os.path.join(current_dir, "arriveico.png")
|
|
msg.setWindowIcon(QIcon(ico_path))
|
|
|
|
msg_style = """
|
|
QMessageBox {
|
|
background-image: url("");
|
|
background-color: #FF80D4;
|
|
border: 1px solid #FF33BB;
|
|
border-radius: 10px;
|
|
font-weight: bold;
|
|
font: 10pt 'Optimism Sans';
|
|
}
|
|
|
|
/* The OK button font and style */
|
|
QPushButton {
|
|
background-color: #FF80D4;
|
|
font-family: "Segoe UI", Arial; /* Set your font here */
|
|
font: 10pt 'Optimism Sans';
|
|
}
|
|
"""
|
|
msg.setStyleSheet(msg_style)
|
|
|
|
if not selected_items:
|
|
msg.setWindowTitle("SELECTION REQUIRED")
|
|
msg.setText("PLEASE SELECT A SPRINT FROM THE TREE FIRST")
|
|
msg.exec()
|
|
return
|
|
|
|
item = selected_items[0]
|
|
|
|
# Check if it's a child (sprint) and not a parent (year)
|
|
if item.parent() is None:
|
|
msg.setWindowTitle("INVALID SELECTION")
|
|
msg.setText("PLEASE SELECT A SPECIFIC SPRINT, NOT A YEAR CATEGORY")
|
|
msg.exec()
|
|
return
|
|
|
|
# Extract ID from string "HW Sprint 2 W04-W05 (ID: 18932)"
|
|
text = item.text(0)
|
|
match = re.search(r"\(ID:\s(\d+)\)", text)
|
|
|
|
if not match:
|
|
return
|
|
|
|
sprint_id = match.group(1)
|
|
self.export_sprint_to_csv(sprint_id)
|
|
|
|
def export_sprint_to_csv(self, sprint_id):
|
|
# Fetch issues for this specific sprint
|
|
issues_url = f"https://{JIRA_DOMAIN}/rest/agile/1.0/sprint/{sprint_id}/issue"
|
|
auth = HTTPBasicAuth(EMAIL, API_TOKEN)
|
|
|
|
msg = QMessageBox()
|
|
msg.setIcon(QMessageBox.Icon.Warning)
|
|
|
|
# Get the absolute path of the directory where the program resides
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
ico_path = os.path.join(current_dir, "arriveico.png")
|
|
msg.setWindowIcon(QIcon(ico_path))
|
|
|
|
msg_style = """
|
|
QMessageBox {
|
|
background-image: url("");
|
|
background-color: #FF80D4;
|
|
border: 1px solid #FF33BB;
|
|
border-radius: 10px;
|
|
font-weight: bold;
|
|
font: 10pt 'Optimism Sans';
|
|
}
|
|
|
|
/* The OK button font and style */
|
|
QPushButton {
|
|
background-color: #FF80D4;
|
|
font-family: "Segoe UI", Arial; /* Set your font here */
|
|
font: 10pt 'Optimism Sans';
|
|
font-weight: bold;
|
|
}
|
|
"""
|
|
msg.setStyleSheet(msg_style)
|
|
|
|
try:
|
|
resp = requests.get(issues_url, auth=auth)
|
|
resp.raise_for_status()
|
|
issues = resp.json().get("issues", [])
|
|
|
|
if not issues:
|
|
msg.setWindowTitle("NO DATA")
|
|
msg.setText("THIS SPRINT CONTAINS NO ISSUES")
|
|
msg.exec()
|
|
#QMessageBox.information(self, "No Data", "This sprint contains no issues.")
|
|
return
|
|
|
|
# Write to CSV
|
|
# Define your target folder
|
|
# Get the absolute path of the directory where the program resides
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
target_folder = os.path.join(current_dir, "EXPORT")
|
|
#target_folder = r"C:\Users\david.rice\Documents\Python\ARRIVE\EXPORTS"
|
|
|
|
# Create the folder if it doesn't exist yet
|
|
if not os.path.exists(target_folder):
|
|
os.makedirs(target_folder)
|
|
|
|
filename = f"SPRINT_{sprint_id}_EXPORT.csv"
|
|
|
|
# Join them together into one path
|
|
full_path = os.path.join(target_folder, filename)
|
|
|
|
with open(full_path, mode='w', newline='', encoding='utf-8') as f:
|
|
writer = csv.writer(f)
|
|
# Header row
|
|
writer.writerow(["Key", "Summary", "Labels", "Original Estimate (Hrs)", "Status", "Priority", "Assignee"])
|
|
for issue in issues:
|
|
fields = issue.get("fields", {})
|
|
|
|
# Get Labels and join them into one string
|
|
labels_list = fields.get("labels", [])
|
|
label_text = ", ".join(labels_list) if labels_list else "NONE"
|
|
|
|
# Get Original Estimate (Seconds -> Hours)
|
|
# Jira returns None if no estimate is set, so we default to 0
|
|
seconds = fields.get("timeoriginalestimate")
|
|
estimate_hrs = (seconds / 3600) if seconds else 0
|
|
|
|
writer.writerow([
|
|
issue.get("key"),
|
|
fields.get("summary"),
|
|
label_text,
|
|
f"{estimate_hrs:.2f}",
|
|
fields.get("status", {}).get("name"),
|
|
fields.get("priority", {}).get("name"),
|
|
fields.get("assignee", {}).get("displayName") if fields.get("assignee") else "UNASSIGNED"
|
|
])
|
|
|
|
msg.setIcon(QMessageBox.Icon.Information)
|
|
msg.setWindowTitle("SUCCESS")
|
|
msg.setText(f"EXPORTED {len(issues)} ISSUES TO {filename}")
|
|
msg.exec()
|
|
|
|
except Exception as e:
|
|
msg.setIcon(QMessageBox.Icon.Critical)
|
|
msg.setWindowTitle("ERROR")
|
|
msg.setText(f"FAILED TO EXPORT: {str(e)}")
|
|
msg.exec()
|
|
|
|
# Run main
|
|
if __name__ == '__main__':
|
|
# Launch main window
|
|
app = QApplication(sys.argv)
|
|
app.setStyle('Fusion')
|
|
window = MainWindow()
|
|
window.show()
|
|
sys.exit(app.exec())
|