floodmeadows / Jira Move Issue to Sprint and Change Status (Persistent Buttons)

// ==UserScript==
// @name         Jira Move Issue to Sprint and Change Status (Persistent Buttons)
// @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
// @license      MIT
// @version      1.0
// @include      https://jira.*.uk/browse/*
// @grant        none
// ==/UserScript==

/* jshint esversion: 8 */

(function() {
  'use strict';

  // -----------------------------
  // CONFIGURATION
  // -----------------------------
  const debug = false;
  const currentUrl = new URL(document.URL);
  const jiraBaseUrl = currentUrl.origin;

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

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

  let issueKey = "";

  // -----------------------------
  // INITIALISE
  // -----------------------------
  function init() {
    issueKey = document.getElementById('key-val')?.textContent.trim();
    addButtons();
  }

  // -----------------------------
  // BUTTON CREATION
  // -----------------------------
    function addButtons() {
        console.log("addButtons called")
        setTimeout(() => {
            const container = document.getElementById('opsbar-opsbar-transitions');
            if (!container) {
                console.log("Button container not found. Can't add buttons.")
                return
            };
            if (document.getElementById('custom-buttons-added')) {
                console.log("Marker element not found. Returning without adding buttons.")
                return;
            }
            console.log("Injecting buttons...");
            const marker = document.createElement('span');
            marker.id = 'custom-buttons-added';
            container.appendChild(marker);

            addLink(container, "Backlog", moveToBacklog);
            addLink(container, "In Analysis", () => changeSprintAndState('inAnalysis'));
            addLink(container, "Ready for Refinement", () => changeSprintAndState('readyForRefinement'));
            addLink(container, "Ready for Dev", () => changeSprintAndState('readyForDev'));
        }, 500); // delay to allow DOM to settle
    }

  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 buttons...");
    addButtons();
  });
  observer.observe(document.body, { childList: true, subtree: true });
  window.__observerActive = true;
  console.log("MutationObserver started");

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