Add refactored jtp script with additional events added
Now tracks changes to summary (title) and description as well.
diff --git a/jira-to-pusub.py b/jira-to-pusub.py
new file mode 100644
index 0000000..7baea3c
--- /dev/null
+++ b/jira-to-pusub.py
@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""JIRA to PyPubSub bridge - polls JIRA for updates and pushes them to pypubsub."""
+import requests
+import time
+import pytz
+import datetime
+import yaml
+import sys
+
+DEFAULT_POLL_INTERVAL = 5 # Default is poll once every five seconds.
+DEFAULT_SETTINGS_FILE = "config.yaml"
+
+# Launch time is recorded, so that we never fetch JIRA changes that are older than this.
+LAUNCH_TIME = time.time()
+
+# Global to keep track of the last time we saw an update for a specific ticket.
+# This is far cheaper than keeping all the JiraTicket objects in memory.
+UPDATES = {}
+
+
+class Config:
+ def __init__(self, config_filepath = DEFAULT_SETTINGS_FILE):
+ self.yaml = yaml.safe_load(open(config_filepath).read())
+ self.jira_url = self.yaml["jira_url"]
+ self.jira_user = self.yaml["jira_user"]
+ self.jira_pass = self.yaml["jira_pass"]
+ self.jira_base = self.yaml["jira_base"]
+ self.pubsub_url = self.yaml["pubsub_url"]
+ self.debug = self.yaml.get("debug", False)
+ self.poll_interval = int(self.yaml.get("polling_internal", DEFAULT_POLL_INTERVAL))
+
+
+class JiraTicket:
+ """Jira Ticket parser class"""
+ def __init__(self, config: Config, json_data: dict):
+ """Init a Jira ticket with the base data."""
+ self.key = json_data["key"]
+ self.link = f"{config.jira_base}{self.key}"
+ self.summary = json_data["fields"].get("summary", "(No summary available)")
+ self.created = self.jira_to_datetime(json_data["fields"]["created"])
+ self.last_update = UPDATES.get(self.key, LAUNCH_TIME)
+ self.events = []
+ self.json_data = json_data
+ self.config = config
+
+ @staticmethod
+ def jira_to_datetime(t: str):
+ """Parses Jira timestamps into datetime objects"""
+ return datetime.datetime.strptime(t, "%Y-%m-%dT%H:%M:%S.%f%z")
+
+ def make_event_dict(self, entry: dict) -> [dict | None]:
+ """Constructs a basic change event from a Jira change-set entry of any type"""
+
+ if "created" not in entry: # All entries must have this field, or we'll break...
+ return
+ change_epoch = self.jira_to_datetime(entry.get("updated", entry["created"])).timestamp()
+
+ # If this change is older than our last seen update, ignore and return None
+ if change_epoch <= self.last_update:
+ return
+
+ # Fetch author name and user ID if present
+ if "author" not in entry and "creator" in entry: # Rewrite author->creator if no author of this entry.
+ entry["author"] = entry["creator"]
+ if "author" in entry:
+ author_name = entry["author"].get("displayName", "??")
+ author_uid = entry["author"].get("name", "??")
+ else:
+ author_name = "??"
+ author_uid = "??"
+
+ # Make the basic event dict
+ base_event = {
+ "key": self.key,
+ "link": self.link,
+ "summary": self.summary,
+ "project": self.key.split("-", 1)[0],
+ "action": "unknown",
+ "author": author_name,
+ "author_uid": author_uid,
+ "timestamp": change_epoch,
+ "base_entry_created": self.jira_to_datetime(entry["created"]).timestamp()
+ }
+ return base_event
+
+ def parse_changelog(self):
+ """Parses a changelog and formats it for humans"""
+ elements = self.json_data.get("changelog", {}).get("histories", [])
+ for change in elements:
+ base_event = self.make_event_dict(change)
+ if not base_event:
+ continue
+
+ # Check all ticket status changes/edits
+ for item in change["items"]:
+ new_event = base_event.copy() # Make a copy of the base event dict for each item
+
+ # Changing resolution
+ if item.get("field") == "resolution":
+ if not item.get("fromString"): # If changing from no res to a res, assume closing as $X
+ res = item.get("toString", "Unresolved")
+ if self.last_update > 0:
+ new_event["action"] = "close"
+ new_event["resolution"] = res
+ new_event["action_human_text"] = f"closed <{self.link}|{self.key}> as _{res}_."
+ self.events.append(new_event)
+ elif self.last_update > 0:
+ old_resolution = item.get("fromString")
+ new_resolution = item.get("toString", "Unresolved")
+ new_event["action"] = "resolution"
+ new_event["from"] = old_resolution
+ new_event["to"] = new_resolution
+ new_event["action_human_text"] = f"changed the resolution of <{self.link}|{self.key}> from "
+ f"_{old_resolution}_ to _{new_resolution}_."
+ self.events.append(new_event)
+
+ # Status change (like WFU/WFI)
+ elif item.get("field") == "status":
+ old_status = item.get("fromString", "")
+ new_status = item.get("toString")
+ if self.last_update > 0:
+ new_event["action"] = "status"
+ new_event["from"] = old_status
+ new_event["to"] = new_status
+ new_event["action_human_text"] = f"changed the status of <{self.link}|{self.key}> to *{new_status}*."
+ self.events.append(new_event)
+
+ # Summary change (editing the title of the ticket)
+ elif item.get("field") == "summary":
+ old_summary = item.get("fromString", "")
+ new_summary = item.get("toString")
+ if self.last_update > 0:
+ new_event["action"] = "summary"
+ new_event["from"] = old_summary
+ new_event["to"] = new_summary
+ new_event["action_human_text"] = f"changed the summary of <{self.link}|{self.key}>"
+ self.events.append(new_event)
+
+ # Description change (editing the main text body of the ticket)
+ elif item.get("field") == "description":
+ old_description = item.get("fromString", "")
+ new_description = item.get("toString")
+ if self.last_update > 0:
+ new_event["action"] = "description"
+ new_event["from"] = old_description
+ new_event["to"] = new_description
+ new_event["action_human_text"] = f"changed the description of <{self.link}|{self.key}>"
+ self.events.append(new_event)
+
+ # Assignee change
+ elif item.get("field") == "assignee":
+ old_assignee = item.get("fromString", "")
+ new_assignee = item.get("toString", "")
+ if self.last_update > 0:
+ new_event["action"] = "assign"
+ new_event["from"] = old_assignee
+ new_event["to"] = new_assignee
+ if new_assignee:
+ new_event["action_human_text"] = f"assigned *{new_assignee}* to <{self.link}|{self.key}>."
+ else:
+ new_event["action_human_text"] = f"unassigned *{old_assignee}* from <{self.link}|{self.key}>."
+ self.events.append(new_event)
+
+ def parse_comments(self):
+ """Parse comment changes and format them for humans"""
+ elements = self.json_data["fields"].get("comment", {}).get("comments", [])
+ for comment in elements:
+ base_event = self.make_event_dict(comment)
+ if not base_event:
+ continue
+
+ # Comment altered?
+ if base_event["base_entry_created"] != base_event["timestamp"]:
+ if self.last_update > 0:
+ base_event["action"] = "comment_edit"
+ base_event["body"] = comment["body"]
+ base_event["action_human_text"] = "edited a comment"
+ self.events.append(base_event)
+
+ # Or comment created?
+ elif self.last_update > 0:
+ base_event["action"] = "comment"
+ base_event["body"] = comment["body"]
+ base_event["action_human_text"] = "added a comment"
+ self.events.append(base_event)
+
+ def parse_worklog(self):
+ """Parses worklog changes and formats it for humans"""
+ elements = self.json_data["fields"].get("worklog", {}).get("worklogs", [])
+ for worklog in elements:
+ base_event = self.make_event_dict(worklog)
+ if not base_event:
+ continue
+
+ # Comment altered?
+ if base_event["base_entry_created"] != base_event["timestamp"]:
+ if self.last_update > 0:
+ base_event["action"] = "comment_edit"
+ base_event["body"] = worklog["comment"]
+ base_event["timespent"] = worklog["timeSpentSeconds"]
+ base_event["action_human_text"] = "edited a worklog entry"
+ self.events.append(base_event)
+
+ # Or comment created?
+ elif self.last_update > 0:
+ base_event["action"] = "comment"
+ base_event["body"] = worklog["comment"]
+ base_event["timespent"] = worklog["timeSpentSeconds"]
+ base_event["action_human_text"] = "added a worklog entry"
+ self.events.append(base_event)
+
+
+def fetch_changes(config: Config):
+ """Fetch the latest changes from JIRA and process them"""
+ try:
+ js = requests.get(config.jira_url, auth=(config.jira_user, config.jira_pass), timeout=15).json()
+ assert isinstance(js, dict), "Jira search did not return a dictionary response"
+ return js.get("issues", [])
+ except requests.RequestException as e: # This should work - if for whatever reason it doesn't, try again later.
+ print(f"Could not contact Jira, waiting for next try: {e}")
+ return []
+ except AssertionError as e:
+ print(f"General assertion error with Jira response: {e}")
+
+
+def process_changes(config: Config, changes: list):
+ now = datetime.datetime.now(pytz.utc)
+
+ for issue in changes:
+ ticket = JiraTicket(config, issue)
+
+ # Is this a brand-new issue?
+ if ticket.last_update == 0 and (now - ticket.created).seconds <= 30:
+ UPDATES[ticket.key] = ticket.created.timestamp()
+ base_event = ticket.make_event_dict(issue["fields"])
+ base_event["action"] = "create"
+ base_event["action_human_text"] = f"created {ticket.key}: {ticket.summary}"
+ base_event["description"] = issue["fields"].get("description", "(No description available)")
+ ticket.events.append(base_event)
+
+ # Parse changelog, comments, worklog
+ ticket.parse_changelog()
+ ticket.parse_comments()
+ ticket.parse_worklog()
+
+ if ticket.events: # If any changes were found, update our "last updated" marker for this ticket.
+ UPDATES[ticket.key] = max([event["timestamp"] for event in ticket.events])
+
+ # Post all new events found to pubsub
+ for event in ticket.events:
+ # Set some basic things for each change-set that we only get from the base ticket entry
+ target_url = config.pubsub_url.format(**event)
+ if config.debug:
+ print(event["timestamp"], event["key"], event["author"], event["action_human_text"])
+ print(event)
+ print("---------------")
+ else:
+ try:
+ requests.post(target_url, json=event, timeout=5)
+ except requests.RequestException as e:
+ print(f"Could not post update for {ticket.key}: {e}")
+
+
+def main():
+ config = Config(sys.argv[1] if (len(sys.argv) > 1) else DEFAULT_SETTINGS_FILE)
+ while True: # Loop the Loop, forever and ever.
+ process_start = time.time() # Log when we started processing this batch
+ change_set = fetch_changes(config) # Fetch latest change-set
+ process_changes(config, change_set) # Process change-set (if any)
+ process_duration = time.time() - process_start # Figure out how long that process took
+ if process_duration < config.poll_interval: # If we need to sleep, sleep, otherwise poll again.
+ time.sleep(config.poll_interval - process_duration)
+
+
+if __name__ == "__main__":
+ main()