floodmeadows / Jira move issue to sprint and change status

// ==UserScript==
// @name         Jira move issue to sprint and change status
// @description  Adds buttons to move the current issue into one or more sprints and change the status, persists after Jira async updates
// @namespace    https://openuserjs.org/users/floodmeadows
// @copyright    2025, floodmeadows (https://openuserjs.org/users/floodmeadows)
// @updateURL    https://openuserjs.org/meta/floodmeadows/Jira_move_issue_to_sprint_and_change_status.meta.js
// @downloadURL  https://openuserjs.org/install/floodmeadows/Jira_move_issue_to_sprint_and_change_status.user.js
// @license      MIT
// @version      1.1
// @include      https://jira.*.uk/browse/*
// @grant        none
// ==/UserScript==

/* jshint esversion: 11 */

(function() {
  'use strict';

  // -----------------------------
  // CONFIGURATION
  // -----------------------------
  const debug = false;
  // markerId needs to be specific to this script to avoid clashes with other scripts that add UI elements.
  const markerToShowWhenButtonsHaveBeenAddedId = 'move-sprint-change-status-buttons-added';

  const sprintIds = {
    inAnalysis: 20090,
    readyForRefinement: 18522,
    readyForDev: 18509
  };

  const transitionIds = {
    inAnalysis: 1321,
    readyForRefinement: 1311,
    readyForDev: 1131,
    toDo: 11
  };

  // -----------------------------
  // INITIALISE
  // -----------------------------
  let issueKey = "";
  let currentUrl = "";
  let jiraBaseUrl = "";

  function init() {
    issueKey = document.getElementById('key-val')?.textContent.trim();
    currentUrl = new URL(document.URL);
    jiraBaseUrl = currentUrl.origin;
    addButtons();
  }

  // -----------------------------
  // BUTTON CREATION
  // -----------------------------
  function checkIfButtonsNeedAdding() {
    console.log("checkIfButtonsNeedAdding called")
    if (document.getElementById(markerToShowWhenButtonsHaveBeenAddedId)) {
      console.log("Marker element found. Buttons already added. Returning without adding buttons.")
      return;
    }
    addButtons();
  }

  function addButtons() {
    // Can we add the buttons?
    const container = document.getElementById('opsbar-opsbar-transitions');
    if (!container) {
      console.log("Button container not found. Can't add buttons.")
      return
    }
    console.log("Adding marker to prevent buttons being added more than once...");
    const marker = document.createElement('span');
    marker.id = markerToShowWhenButtonsHaveBeenAddedId;
    container.appendChild(marker);

    console.log("Adding buttons...");
    addLink(container, "Backlog", moveToBacklog);
    addLink(container, "In Analysis", () => changeSprintAndState('inAnalysis'));
    addLink(container, "Ready for Refinement", () => changeSprintAndState('readyForRefinement'));
    addLink(container, "Ready for Dev", () => changeSprintAndState('readyForDev'));
  }

  function addLink(container, text, handler) {
    const btn = document.createElement("a");
    btn.href = "#";
    btn.className = "aui-button toolbar-trigger issueaction-workflow-transition";
    btn.style.marginLeft = "10px";
    btn.textContent = text;
    btn.addEventListener("click", handler);
    container.appendChild(btn);
  }

  // -----------------------------
  // BUSINESS LOGIC
  // -----------------------------
  async function changeSprintAndState(key) {
    const url = `${jiraBaseUrl}/rest/agile/latest/sprint/${sprintIds[key]}/issue`;
    const headers = new Headers({ "Content-Type": "application/json" });
    const body = JSON.stringify({ "issues": [ issueKey ] });

    try {
      console.log(`Moving ${issueKey} to sprint ${key}`);
      const response = await fetch(url, { method: 'POST', headers, body });
      console.log("Sprint move response:", await response.text());

      await applyTransition(transitionIds[key]);
      reloadPage();
    } catch (error) {
      console.error('Sprint move error:', error);
    }
  }

  async function moveToBacklog() {
    const url = `${jiraBaseUrl}/rest/agile/latest/backlog/issue`;
    const headers = new Headers({ "Content-Type": "application/json" });
    const body = JSON.stringify({ "issues": [ issueKey ] });

    try {
      console.log(`Moving ${issueKey} to backlog`);
      const response = await fetch(url, { method: 'POST', headers, body });
      console.log("Backlog move response:", await response.text());

      await applyTransition(transitionIds.toDo);
      reloadPage();
    } catch (error) {
      console.error('Backlog move error:', error);
    }
  }

  async function applyTransition(transitionId) {
    const url = `${jiraBaseUrl}/rest/api/2/issue/${issueKey}/transitions`;
    const headers = new Headers({ "Content-Type": "application/json" });
    const body = JSON.stringify({ "transition": { "id": transitionId } });

    try {
      console.log(`Applying transition ${transitionId} to ${issueKey}`);
      const response = await fetch(url, { method: 'POST', headers, body });
      console.log("Transition response:", await response.status);
    } catch (error) {
      console.error('Transition error:', error);
    }
  }

  function reloadPage() {
    if (!debug) window.location.assign(currentUrl);
  }

  // -----------------------------
  // OBSERVER TO HANDLE DOM CHANGES
  // -----------------------------
  const observer = new MutationObserver((mutationsList, observer) => {
    console.log("DOM changed. Checking if we need to re-add the buttons...");
    setTimeout(() => {
      checkIfButtonsNeedAdding();
    }, 500); // delay to allow DOM to settle before checking
  });
  observer.observe(document.body, { childList: true, subtree: true });
  window.__observerActive = true;
  console.log("MutationObserver started");

  // Run on initial load
  init();
})();