first commit
This commit is contained in:
BIN
__pycache__/ui_mainwindow.cpython-313.pyc
Normal file
BIN
__pycache__/ui_mainwindow.cpython-313.pyc
Normal file
Binary file not shown.
BIN
appbackground.jpg
Normal file
BIN
appbackground.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
arriveico.png
Normal file
BIN
arriveico.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
291
main.py
Normal file
291
main.py
Normal file
@@ -0,0 +1,291 @@
|
||||
#!/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';
|
||||
font-weight: bold;
|
||||
}
|
||||
"""
|
||||
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())
|
||||
55
sprint_exporter.py
Normal file
55
sprint_exporter.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
# --- 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
|
||||
|
||||
auth = HTTPBasicAuth(EMAIL, API_TOKEN)
|
||||
headers = {"Accept": "application/json"}
|
||||
# --- Get All Sprints for the Board ---
|
||||
sprints_url = f"https://{JIRA_DOMAIN}/rest/agile/1.0/board/{BOARD_ID}/sprint"
|
||||
sprints = []
|
||||
start_at = 0
|
||||
max_results = 50
|
||||
|
||||
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()
|
||||
sprints.extend(data.get("values", []))
|
||||
|
||||
if data["isLast"]:
|
||||
break
|
||||
|
||||
start_at += max_results
|
||||
|
||||
print(f"Found {len(sprints)} sprints.")
|
||||
|
||||
# --- Loop Through Each Sprint and Get Issues ---
|
||||
for sprint in sprints:
|
||||
sprint_id = sprint["id"]
|
||||
sprint_name = sprint["name"]
|
||||
print(f"\nSprint: {sprint_name} (ID: {sprint_id})")
|
||||
issues_url = f"https://{JIRA_DOMAIN}/rest/agile/1.0/sprint/{sprint_id}/issue"
|
||||
start_at_issues = 0
|
||||
|
||||
while True:
|
||||
params = {"startAt": start_at_issues, "maxResults": max_results}
|
||||
issues_resp = requests.get(issues_url, headers=headers, auth=auth, params=params)
|
||||
issues_resp.raise_for_status()
|
||||
issues_data = issues_resp.json()
|
||||
issues = issues_data.get("issues", [])
|
||||
|
||||
for issue in issues:
|
||||
key = issue["key"]
|
||||
summary = issue["fields"]["summary"]
|
||||
print(f" {key}: {summary}")
|
||||
|
||||
if start_at_issues + max_results >= issues_data["total"]:
|
||||
break
|
||||
|
||||
start_at_issues += max_results
|
||||
133
ui_mainwindow.py
Normal file
133
ui_mainwindow.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect, Qt)
|
||||
from PySide6.QtGui import (QFont, QIcon)
|
||||
from PySide6.QtWidgets import (QFrame, QLabel, QPushButton, QWidget, QTreeWidget)
|
||||
|
||||
|
||||
class Ui_MainWindow(object):
|
||||
def setupUi(self, MainWindow):
|
||||
if not MainWindow.objectName():
|
||||
MainWindow.setObjectName(u"MainWindow")
|
||||
|
||||
# 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")
|
||||
|
||||
MainWindow.setToolButtonStyle(Qt.ToolButtonIconOnly)
|
||||
MainWindow.setAnimated(True)
|
||||
MainWindow.setDocumentMode(False)
|
||||
MainWindow.setWindowIcon(QIcon(ico_path))
|
||||
fontmain = QFont()
|
||||
fontmain.setFamilies([u"Optimism Sans"])
|
||||
fontmain.setPointSize(10)
|
||||
fontmain.setBold(True)
|
||||
|
||||
MainWindow.setFont(fontmain)
|
||||
|
||||
image_path = os.path.join(current_dir, "appbackground.jpg")
|
||||
|
||||
image_path_css = image_path.replace("\\", "/")
|
||||
|
||||
# --- Define and Apply the Style Sheet ---
|
||||
bg_style_sheet = f"""
|
||||
QWidget {{
|
||||
background-image: url("{image_path_css}");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}}
|
||||
"""
|
||||
|
||||
self.centralwidget = QWidget(MainWindow)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.centralwidget.setStyleSheet(bg_style_sheet)
|
||||
self.header = QLabel(self.centralwidget)
|
||||
self.header.setObjectName(u"header")
|
||||
self.header.setGeometry(QRect(50, 35, 650, 50))
|
||||
font = QFont()
|
||||
font.setFamilies([u"Optimism Sans"])
|
||||
font.setPointSize(24)
|
||||
font.setBold(True)
|
||||
|
||||
self.header.setFont(font)
|
||||
self.header.setStyleSheet("color: #5F016F;")
|
||||
self.header.setAlignment(Qt.AlignCenter)
|
||||
|
||||
self.test_area = QFrame(self.centralwidget)
|
||||
self.test_area.setObjectName(u"test_area")
|
||||
self.test_area.setGeometry(QRect(50, 115, 924, 618))
|
||||
self.test_area.setFrameShape(QFrame.StyledPanel)
|
||||
self.test_area.setFrameShadow(QFrame.Raised)
|
||||
|
||||
button_style = """
|
||||
QPushButton {
|
||||
background-color: #FF80D4;
|
||||
border: 1px solid #FF33BB;
|
||||
border-radius: 1px;
|
||||
}
|
||||
"""
|
||||
|
||||
self.connButton = QPushButton(self.test_area)
|
||||
self.connButton.setObjectName(u"connButton")
|
||||
self.connButton.setGeometry(QRect(640, 25, 125, 25))
|
||||
self.connButton.setFont(fontmain)
|
||||
self.connButton.setStyleSheet(button_style)
|
||||
|
||||
text_label_style = """
|
||||
QLabel {
|
||||
background-image: url("");
|
||||
background-color: #FF80D4;
|
||||
border: 0px solid #FF33BB;
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
frame_style = """
|
||||
QFrame {
|
||||
background-image: url("");
|
||||
background-color: #FF80D4;
|
||||
border: 1px solid #FF33BB;
|
||||
border-radius: 10px;
|
||||
}
|
||||
"""
|
||||
|
||||
tree_style = """
|
||||
QHeaderView::section {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 5px;
|
||||
font-weight: bold;
|
||||
font: 10pt 'Optimism Sans';
|
||||
}
|
||||
"""
|
||||
|
||||
self.test_area.setStyleSheet(frame_style)
|
||||
|
||||
self.tree = QTreeWidget(self.test_area)
|
||||
self.tree.header().setDefaultAlignment(Qt.AlignCenter)
|
||||
self.tree.setGeometry(QRect(25, 25, 450, 565))
|
||||
self.tree.setStyleSheet(tree_style)
|
||||
self.tree.setColumnCount(1)
|
||||
self.tree.setFont(fontmain)
|
||||
self.tree.header().setFont(fontmain)
|
||||
self.tree.setHeaderLabels(["SPRINT"])
|
||||
|
||||
MainWindow.setCentralWidget(self.centralwidget)
|
||||
self.header.raise_()
|
||||
self.test_area.raise_()
|
||||
|
||||
self.retranslateUi(MainWindow)
|
||||
|
||||
QMetaObject.connectSlotsByName(MainWindow)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, MainWindow):
|
||||
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"JIRA SPRINT EXPORTER V1.0",
|
||||
None))
|
||||
self.header.setText(QCoreApplication.translate("MainWindow", u"JIRA SPRINT EXPORTER", None))
|
||||
self.connButton.setText(QCoreApplication.translate("MainWindow", u"GENERATE", None))
|
||||
|
||||
# retranslateUi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user