commit 07bae18e18d71fd98a4edf2c9d5994613d22a6f3 Author: David Rice Date: Thu Mar 19 14:36:35 2026 +0000 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..84d0202 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Introduction +Jira Sprint Exporter - Python diff --git a/__pycache__/ui_mainwindow.cpython-313.pyc b/__pycache__/ui_mainwindow.cpython-313.pyc new file mode 100644 index 0000000..2ff4fb8 Binary files /dev/null and b/__pycache__/ui_mainwindow.cpython-313.pyc differ diff --git a/appbackground.jpg b/appbackground.jpg new file mode 100644 index 0000000..1c32dc9 Binary files /dev/null and b/appbackground.jpg differ diff --git a/arriveico.png b/arriveico.png new file mode 100644 index 0000000..ed9e56e Binary files /dev/null and b/arriveico.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..9d83c2c --- /dev/null +++ b/main.py @@ -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()) diff --git a/sprint_exporter.py b/sprint_exporter.py new file mode 100644 index 0000000..4d27be0 --- /dev/null +++ b/sprint_exporter.py @@ -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 \ No newline at end of file diff --git a/ui_mainwindow.py b/ui_mainwindow.py new file mode 100644 index 0000000..23a48f1 --- /dev/null +++ b/ui_mainwindow.py @@ -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 + +