@bridge_gpt/mcp-server 0.2.19 → 0.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/build/agents.generated.js +1 -1
- package/build/commands.generated.js +4 -3
- package/build/conductor/local-merge.js +458 -95
- package/build/estimate-epic.js +84 -0
- package/build/executor/job-runner.js +151 -17
- package/build/executor/merge-job.js +84 -10
- package/build/executor/worker-finalization.js +98 -18
- package/build/index.js +1837 -397
- package/build/pipelines.generated.js +16 -20
- package/build/readme.generated.js +1 -1
- package/build/sfcc/client.js +192 -50
- package/build/sfcc/ocapi-write-faults.js +94 -0
- package/build/sfcc/permissions.js +7 -22
- package/build/sfcc/register.js +9 -0
- package/build/sfcc/write-grants.js +80 -0
- package/build/sfcc/write-guard.js +39 -0
- package/build/sfcc/write-result.js +47 -0
- package/build/sfcc/write-tool-common.js +85 -0
- package/build/sfcc/writes-custom-object-def.js +141 -0
- package/build/sfcc/writes-object-attribute-payloads.js +97 -0
- package/build/sfcc/writes-site-preference-payloads.js +59 -0
- package/build/sfcc/writes-site-preference.js +96 -0
- package/build/sfcc/writes-system-object-payloads.js +213 -0
- package/build/sfcc/writes-system-object.js +348 -0
- package/build/sfcc/writes.js +66 -0
- package/build/version.generated.js +1 -1
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +7 -0
- package/pipelines/review-ticket.json +5 -18
- package/public/css/main.min.css +1583 -117
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +2792 -449
- package/public/js/main.min.js.map +1 -1
package/public/js/main.min.js
CHANGED
|
@@ -12,6 +12,18 @@ __webpack_require__.r(__webpack_exports__);
|
|
|
12
12
|
// extracted by mini-css-extract-plugin
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
/***/ },
|
|
16
|
+
|
|
17
|
+
/***/ "./src/css/get-started.css"
|
|
18
|
+
/*!*********************************!*\
|
|
19
|
+
!*** ./src/css/get-started.css ***!
|
|
20
|
+
\*********************************/
|
|
21
|
+
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
|
22
|
+
|
|
23
|
+
__webpack_require__.r(__webpack_exports__);
|
|
24
|
+
// extracted by mini-css-extract-plugin
|
|
25
|
+
|
|
26
|
+
|
|
15
27
|
/***/ },
|
|
16
28
|
|
|
17
29
|
/***/ "./src/css/globals.css"
|
|
@@ -72,6 +84,18 @@ __webpack_require__.r(__webpack_exports__);
|
|
|
72
84
|
// extracted by mini-css-extract-plugin
|
|
73
85
|
|
|
74
86
|
|
|
87
|
+
/***/ },
|
|
88
|
+
|
|
89
|
+
/***/ "./src/css/run-dashboard.css"
|
|
90
|
+
/*!***********************************!*\
|
|
91
|
+
!*** ./src/css/run-dashboard.css ***!
|
|
92
|
+
\***********************************/
|
|
93
|
+
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
|
94
|
+
|
|
95
|
+
__webpack_require__.r(__webpack_exports__);
|
|
96
|
+
// extracted by mini-css-extract-plugin
|
|
97
|
+
|
|
98
|
+
|
|
75
99
|
/***/ },
|
|
76
100
|
|
|
77
101
|
/***/ "./src/css/setup-detail.css"
|
|
@@ -1780,249 +1804,1270 @@ function showDeleteModal(projectName, onConfirm, onCancel) {
|
|
|
1780
1804
|
|
|
1781
1805
|
/***/ },
|
|
1782
1806
|
|
|
1783
|
-
/***/ "./src/js/
|
|
1784
|
-
|
|
1785
|
-
!*** ./src/js/
|
|
1786
|
-
|
|
1807
|
+
/***/ "./src/js/get-started.js"
|
|
1808
|
+
/*!*******************************!*\
|
|
1809
|
+
!*** ./src/js/get-started.js ***!
|
|
1810
|
+
\*******************************/
|
|
1787
1811
|
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
|
1788
1812
|
|
|
1789
1813
|
__webpack_require__.r(__webpack_exports__);
|
|
1790
1814
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
1791
|
-
/* harmony export */
|
|
1792
|
-
/* harmony export */ renderUI: () => (/* binding */ renderUI)
|
|
1815
|
+
/* harmony export */ init: () => (/* binding */ init)
|
|
1793
1816
|
/* harmony export */ });
|
|
1794
|
-
/* harmony import */ var
|
|
1795
|
-
/* harmony import */ var
|
|
1796
|
-
/* harmony import */ var _optimize_context_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./optimize/context.js */ "./src/js/optimize/context.js");
|
|
1797
|
-
/* harmony import */ var _optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./optimize/status_adapters.js */ "./src/js/optimize/status_adapters.js");
|
|
1798
|
-
/* harmony import */ var _optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./optimize/polling.js */ "./src/js/optimize/polling.js");
|
|
1799
|
-
/* harmony import */ var _optimize_feedback_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./optimize/feedback.js */ "./src/js/optimize/feedback.js");
|
|
1800
|
-
/* harmony import */ var _optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./optimize/learn_repository_renderer.js */ "./src/js/optimize/learn_repository_renderer.js");
|
|
1801
|
-
/**
|
|
1802
|
-
* Learn Repository feature for the Optimize page.
|
|
1803
|
-
*
|
|
1804
|
-
* Routed through the shared Optimize plumbing (BAPI-307): canonical identity from
|
|
1805
|
-
* getOptimizeContext, status normalization via the pure adapters, declarative
|
|
1806
|
-
* per-section rendering via the renderer, polling via pollUntil, and inline
|
|
1807
|
-
* feedback via setInlineStatus. Initialization is driven by the single Optimize
|
|
1808
|
-
* page entrypoint (optimize.js) — this module no longer self-registers a
|
|
1809
|
-
* DOMContentLoaded listener. Phase 2 removed the global Begin/Resume CTA in
|
|
1810
|
-
* favor of the per-section "Run Analysis" model.
|
|
1811
|
-
*/
|
|
1812
|
-
|
|
1817
|
+
/* harmony import */ var _modal_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./modal.js */ "./src/js/modal.js");
|
|
1818
|
+
/* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils.js */ "./src/js/utils.js");
|
|
1813
1819
|
|
|
1814
1820
|
|
|
1815
1821
|
|
|
1822
|
+
// ============================================================
|
|
1823
|
+
// Install-key constants
|
|
1824
|
+
// ============================================================
|
|
1825
|
+
|
|
1826
|
+
// Onboarding-generated keys are always stamped as admin "Easy Install" keys so
|
|
1827
|
+
// they are recognizable on the Security page and authorized for /install-bridge.
|
|
1828
|
+
const INSTALL_KEY_NAME = 'Easy Install';
|
|
1829
|
+
const INSTALL_KEY_ROLE = 'admin';
|
|
1830
|
+
const INSTALL_KEY_EMAIL_DOMAIN = 'bridgegpt-api.com';
|
|
1831
|
+
|
|
1832
|
+
// User-facing copy for the GitHub App connect flow (BAPI-506). Kept sanitized —
|
|
1833
|
+
// no backend detail, connection code, or install URL is ever surfaced or logged.
|
|
1834
|
+
const GITHUB_CONNECT_MESSAGES = {
|
|
1835
|
+
connecting: 'Connecting to GitHub…',
|
|
1836
|
+
connectButton: 'Connect GitHub',
|
|
1837
|
+
connectionError: 'We could not start the GitHub connection. Please try again.',
|
|
1838
|
+
missingRepo: "We could not determine this project's repository. Reload the page and try again.",
|
|
1839
|
+
'verification-failed': 'GitHub could not verify this installation. Please try connecting again.',
|
|
1840
|
+
'invalid': 'This GitHub connection link is no longer valid. Please start the connection again.',
|
|
1841
|
+
'expired': 'This GitHub connection link has expired. Please start the connection again.',
|
|
1842
|
+
'already-consumed': 'This GitHub connection link has already been used. Please start the connection again.',
|
|
1843
|
+
'unresolved': 'GitHub returned without completing the connection. Please try again.',
|
|
1844
|
+
'no-repositories': 'The GitHub App installation did not include any repositories Bridge can access.',
|
|
1845
|
+
'already-linked': 'This GitHub installation is already connected to a different Bridge account. If this is unexpected, contact support.',
|
|
1846
|
+
'binding-failed': 'GitHub was verified, but Bridge could not finish linking the repository. Use the manual fallback below.',
|
|
1847
|
+
pickerLoading: 'Loading the repositories from your GitHub installation…',
|
|
1848
|
+
pickerEmpty: 'No repositories were available to select. Use the manual fallback below.',
|
|
1849
|
+
pickerPrompt: 'Your GitHub installation includes multiple repositories. Choose the one to connect to this project:',
|
|
1850
|
+
connectedTitle: 'GitHub connected',
|
|
1851
|
+
};
|
|
1816
1852
|
|
|
1853
|
+
// Holds the plaintext minted key for the current page lifetime ONLY. Never
|
|
1854
|
+
// persisted to storage, cookies, URLs, datasets, or sent back to the server.
|
|
1855
|
+
let generatedInstallKey = null;
|
|
1856
|
+
|
|
1857
|
+
// ============================================================
|
|
1858
|
+
// Clipboard helper
|
|
1859
|
+
// ============================================================
|
|
1860
|
+
|
|
1861
|
+
// Centralized, keyboard-accessible clipboard write used by snippet copy,
|
|
1862
|
+
// /install-bridge handoff copy, and the generated-key copy. Falls back to a
|
|
1863
|
+
// hidden textarea + execCommand when the async clipboard API is unavailable.
|
|
1864
|
+
// Does NOT log the copied text — it may be a plaintext API key.
|
|
1865
|
+
async function copyTextToClipboard(text) {
|
|
1866
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1867
|
+
await navigator.clipboard.writeText(text);
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
const ta = document.createElement('textarea');
|
|
1871
|
+
ta.value = text;
|
|
1872
|
+
ta.style.position = 'fixed';
|
|
1873
|
+
ta.style.opacity = '0';
|
|
1874
|
+
document.body.appendChild(ta);
|
|
1875
|
+
ta.focus();
|
|
1876
|
+
ta.select();
|
|
1877
|
+
try {
|
|
1878
|
+
document.execCommand('copy');
|
|
1879
|
+
} finally {
|
|
1880
|
+
document.body.removeChild(ta);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// Derive a stable, non-empty synthetic email for the minted key. Setup sessions
|
|
1885
|
+
// expose client_id, not a user email, but the key-creation route only requires
|
|
1886
|
+
// a non-empty email — this address is repo-derived, stable, and recognizable in
|
|
1887
|
+
// the Security page key list.
|
|
1888
|
+
function deriveInstallKeyEmail(repoName) {
|
|
1889
|
+
let sanitized = String(repoName == null ? '' : repoName)
|
|
1890
|
+
.trim()
|
|
1891
|
+
.toLowerCase()
|
|
1892
|
+
.replace(/[^a-z0-9._+-]/g, '-')
|
|
1893
|
+
// Trim leading/trailing punctuation that would make the local part awkward.
|
|
1894
|
+
.replace(/^[._+-]+/, '')
|
|
1895
|
+
.replace(/[._+-]+$/, '');
|
|
1896
|
+
if (!sanitized) sanitized = 'project';
|
|
1897
|
+
return `install+${sanitized}@${INSTALL_KEY_EMAIL_DOMAIN}`;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// ============================================================
|
|
1901
|
+
// Snippet tabs
|
|
1902
|
+
// ============================================================
|
|
1903
|
+
|
|
1904
|
+
function renderSnippetTabs(snippets) {
|
|
1905
|
+
const tabList = document.getElementById('editor-tabs');
|
|
1906
|
+
const panelContainer = document.getElementById('editor-tab-panels');
|
|
1907
|
+
if (!tabList || !panelContainer) return;
|
|
1908
|
+
|
|
1909
|
+
tabList.innerHTML = '';
|
|
1910
|
+
panelContainer.innerHTML = '';
|
|
1911
|
+
|
|
1912
|
+
snippets.forEach((snippet, idx) => {
|
|
1913
|
+
const panelId = `snippet-panel-${snippet.id}`;
|
|
1914
|
+
const tabId = `snippet-tab-${snippet.id}`;
|
|
1915
|
+
const isFirst = idx === 0;
|
|
1916
|
+
|
|
1917
|
+
// Tab button
|
|
1918
|
+
const btn = document.createElement('button');
|
|
1919
|
+
btn.type = 'button';
|
|
1920
|
+
btn.id = tabId;
|
|
1921
|
+
btn.className = 'snippet-tab-btn' + (isFirst ? ' active' : '');
|
|
1922
|
+
btn.setAttribute('role', 'tab');
|
|
1923
|
+
btn.setAttribute('aria-selected', isFirst ? 'true' : 'false');
|
|
1924
|
+
btn.setAttribute('aria-controls', panelId);
|
|
1925
|
+
btn.setAttribute('tabindex', isFirst ? '0' : '-1');
|
|
1926
|
+
btn.textContent = snippet.label;
|
|
1927
|
+
btn.addEventListener('click', () => activateTab(idx, snippets));
|
|
1928
|
+
tabList.appendChild(btn);
|
|
1929
|
+
|
|
1930
|
+
// Panel
|
|
1931
|
+
const panel = document.createElement('div');
|
|
1932
|
+
panel.id = panelId;
|
|
1933
|
+
panel.className = 'snippet-panel' + (isFirst ? '' : ' hidden');
|
|
1934
|
+
panel.setAttribute('role', 'tabpanel');
|
|
1935
|
+
panel.setAttribute('aria-labelledby', tabId);
|
|
1936
|
+
if (!isFirst) panel.hidden = true;
|
|
1937
|
+
|
|
1938
|
+
// Metadata row
|
|
1939
|
+
const meta = document.createElement('div');
|
|
1940
|
+
meta.className = 'snippet-meta';
|
|
1941
|
+
meta.textContent = `${snippet.target_path} · ${snippet.language}`;
|
|
1942
|
+
panel.appendChild(meta);
|
|
1943
|
+
|
|
1944
|
+
if (snippet.notes) {
|
|
1945
|
+
const notes = document.createElement('p');
|
|
1946
|
+
notes.className = 'snippet-notes';
|
|
1947
|
+
notes.textContent = snippet.notes;
|
|
1948
|
+
panel.appendChild(notes);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// Code block built from parts
|
|
1952
|
+
const pre = document.createElement('pre');
|
|
1953
|
+
const code = document.createElement('code');
|
|
1954
|
+
code.className = `language-${snippet.language}`;
|
|
1955
|
+
|
|
1956
|
+
(snippet.parts || []).forEach(part => {
|
|
1957
|
+
if (part.kind === 'api_key') {
|
|
1958
|
+
const span = document.createElement('span');
|
|
1959
|
+
span.setAttribute('data-bapi-api-key', '');
|
|
1960
|
+
span.className = 'bapi-api-key-slot';
|
|
1961
|
+
span.textContent = part.text;
|
|
1962
|
+
code.appendChild(span);
|
|
1963
|
+
} else {
|
|
1964
|
+
code.appendChild(document.createTextNode(part.text));
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
pre.appendChild(code);
|
|
1968
|
+
panel.appendChild(pre);
|
|
1817
1969
|
|
|
1970
|
+
// Copy row
|
|
1971
|
+
const copyRow = document.createElement('div');
|
|
1972
|
+
copyRow.className = 'snippet-copy-row';
|
|
1818
1973
|
|
|
1974
|
+
const copyBtn = document.createElement('button');
|
|
1975
|
+
copyBtn.type = 'button';
|
|
1976
|
+
copyBtn.className = 'btn btn-secondary snippet-copy-btn';
|
|
1977
|
+
copyBtn.textContent = 'Copy';
|
|
1819
1978
|
|
|
1979
|
+
const copyStatus = document.createElement('span');
|
|
1980
|
+
copyStatus.className = 'copy-status';
|
|
1981
|
+
copyStatus.setAttribute('aria-live', 'polite');
|
|
1820
1982
|
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1983
|
+
copyBtn.addEventListener('click', async () => {
|
|
1984
|
+
const text = code.textContent;
|
|
1985
|
+
try {
|
|
1986
|
+
await copyTextToClipboard(text);
|
|
1987
|
+
copyStatus.textContent = 'Copied!';
|
|
1988
|
+
setTimeout(() => { copyStatus.textContent = ''; }, 2000);
|
|
1989
|
+
} catch (_) {
|
|
1990
|
+
copyStatus.textContent = 'Copy failed';
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1824
1993
|
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
const LEARNING_POLL_TIMEOUT_MS = 600000;
|
|
1994
|
+
copyRow.appendChild(copyBtn);
|
|
1995
|
+
copyRow.appendChild(copyStatus);
|
|
1996
|
+
panel.appendChild(copyRow);
|
|
1829
1997
|
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
// the human-facing display name).
|
|
1833
|
-
let currentRepoName = null;
|
|
1998
|
+
panelContainer.appendChild(panel);
|
|
1999
|
+
});
|
|
1834
2000
|
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
async function initLearnRepository(context = (0,_optimize_context_js__WEBPACK_IMPORTED_MODULE_2__.getOptimizeContext)()) {
|
|
1840
|
-
currentRepoName = context && context.repoName;
|
|
1841
|
-
if (!currentRepoName) {
|
|
1842
|
-
console.error('Repository name not available');
|
|
1843
|
-
return;
|
|
2001
|
+
// If a key was already minted during this page lifetime, re-inject it so the
|
|
2002
|
+
// snippet contract stays intact across re-renders (e.g. a retry).
|
|
2003
|
+
if (generatedInstallKey) {
|
|
2004
|
+
injectInstallKeyIntoSnippets(generatedInstallKey);
|
|
1844
2005
|
}
|
|
2006
|
+
}
|
|
1845
2007
|
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
'GET'
|
|
1851
|
-
);
|
|
1852
|
-
if (data) {
|
|
1853
|
-
renderUI(data);
|
|
1854
|
-
} else {
|
|
1855
|
-
showStatusMessage('Failed to load repository learning status', 'error');
|
|
1856
|
-
}
|
|
1857
|
-
} catch (error) {
|
|
1858
|
-
console.error('Error fetching learn repository status:', error);
|
|
1859
|
-
showStatusMessage('Error loading repository learning status', 'error');
|
|
1860
|
-
}
|
|
2008
|
+
function activateTab(idx, snippets) {
|
|
2009
|
+
const tabList = document.getElementById('editor-tabs');
|
|
2010
|
+
const panelContainer = document.getElementById('editor-tab-panels');
|
|
2011
|
+
if (!tabList || !panelContainer) return;
|
|
1861
2012
|
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
}
|
|
2013
|
+
const tabs = tabList.querySelectorAll('[role="tab"]');
|
|
2014
|
+
const panels = panelContainer.querySelectorAll('[role="tabpanel"]');
|
|
1865
2015
|
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
2016
|
+
tabs.forEach((tab, i) => {
|
|
2017
|
+
const active = i === idx;
|
|
2018
|
+
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
2019
|
+
tab.setAttribute('tabindex', active ? '0' : '-1');
|
|
2020
|
+
tab.classList.toggle('active', active);
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
panels.forEach((panel, i) => {
|
|
2024
|
+
const active = i === idx;
|
|
2025
|
+
panel.hidden = !active;
|
|
2026
|
+
panel.classList.toggle('hidden', !active);
|
|
2027
|
+
});
|
|
1869
2028
|
}
|
|
1870
2029
|
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
* When the server reports a run in progress and no poll is active, a global poll
|
|
1875
|
-
* is started (idempotent — safe to call from re-renders inside the poll itself).
|
|
1876
|
-
* @param {Object} data
|
|
1877
|
-
*/
|
|
1878
|
-
function renderUI(data) {
|
|
1879
|
-
updateLatestLearnRepositoryStatus(data);
|
|
2030
|
+
// ============================================================
|
|
2031
|
+
// /install-bridge hand-off
|
|
2032
|
+
// ============================================================
|
|
1880
2033
|
|
|
1881
|
-
|
|
2034
|
+
function initializeHandoffCopy() {
|
|
2035
|
+
const btn = document.getElementById('handoff-copy-btn');
|
|
2036
|
+
const status = document.getElementById('handoff-copy-status');
|
|
2037
|
+
if (!btn || !status) return;
|
|
1882
2038
|
|
|
1883
|
-
|
|
1884
|
-
|
|
2039
|
+
btn.addEventListener('click', async () => {
|
|
2040
|
+
try {
|
|
2041
|
+
await copyTextToClipboard('/install-bridge');
|
|
2042
|
+
status.textContent = 'Copied!';
|
|
2043
|
+
setTimeout(() => { status.textContent = ''; }, 2000);
|
|
2044
|
+
} catch (_) {
|
|
2045
|
+
status.textContent = 'Copy failed';
|
|
2046
|
+
}
|
|
1885
2047
|
});
|
|
2048
|
+
}
|
|
1886
2049
|
|
|
1887
|
-
|
|
1888
|
-
|
|
2050
|
+
// ============================================================
|
|
2051
|
+
// Config-status readout
|
|
2052
|
+
// ============================================================
|
|
2053
|
+
|
|
2054
|
+
// Whitelist mapping known integration ids to the setup-detail field anchors
|
|
2055
|
+
// their credential is configured under. Only ids present here contribute a URL
|
|
2056
|
+
// fragment — arbitrary server-provided ids never become a fragment.
|
|
2057
|
+
const INTEGRATION_SETUP_ANCHORS = {
|
|
2058
|
+
jira: 'jira_api_token',
|
|
2059
|
+
github_app: 'git_app_installation_id',
|
|
2060
|
+
vcs_access_token: 'version_control_access_token',
|
|
2061
|
+
vcs_webhook: 'vcs_webhook_secret',
|
|
2062
|
+
};
|
|
2063
|
+
|
|
2064
|
+
// Read the projectId query param from the current URL. Returns null when absent.
|
|
2065
|
+
function getProjectIdFromUrl() {
|
|
2066
|
+
return new URLSearchParams(window.location.search).get('projectId');
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// Build a setup-detail href, preserving the projectId param and (optionally)
|
|
2070
|
+
// deep-linking to a specific field anchor. Both projectId and fragment are
|
|
2071
|
+
// URL-encoded; an absent projectId yields the bare path, an empty fragment is
|
|
2072
|
+
// omitted.
|
|
2073
|
+
function buildSetupDetailHref(projectId, fragmentId) {
|
|
2074
|
+
let href = projectId
|
|
2075
|
+
? `/setup/setup-detail?projectId=${encodeURIComponent(projectId)}`
|
|
2076
|
+
: '/setup/setup-detail';
|
|
2077
|
+
if (fragmentId) {
|
|
2078
|
+
href += `#${encodeURIComponent(fragmentId)}`;
|
|
2079
|
+
}
|
|
2080
|
+
return href;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
// Resolve the setup-detail field anchor for an integration item from the
|
|
2084
|
+
// whitelist. Returns '' for missing/unknown ids so arbitrary server data is
|
|
2085
|
+
// never used as a URL fragment.
|
|
2086
|
+
function getIntegrationSetupFragment(integration) {
|
|
2087
|
+
const id = integration && integration.id;
|
|
2088
|
+
if (!id) return '';
|
|
2089
|
+
return INTEGRATION_SETUP_ANCHORS[id] || '';
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// Normalize an integration's required_for descriptions into display text.
|
|
2093
|
+
// Accepts an array (stringified, trimmed, empties dropped, comma-joined) or a
|
|
2094
|
+
// plain string (trimmed). Returns '' for missing/null/unsupported values.
|
|
2095
|
+
function formatRequiredForText(requiredFor) {
|
|
2096
|
+
if (Array.isArray(requiredFor)) {
|
|
2097
|
+
return requiredFor
|
|
2098
|
+
.map(item => (item == null ? '' : String(item).trim()))
|
|
2099
|
+
.filter(item => item !== '')
|
|
2100
|
+
.join(', ');
|
|
2101
|
+
}
|
|
2102
|
+
if (typeof requiredFor === 'string') {
|
|
2103
|
+
return requiredFor.trim();
|
|
1889
2104
|
}
|
|
2105
|
+
return '';
|
|
1890
2106
|
}
|
|
1891
2107
|
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2108
|
+
// True only when the item carries a non-null setup_instructions object. Used
|
|
2109
|
+
// only to choose link copy — get-started never renders instruction prose.
|
|
2110
|
+
function hasSetupInstructions(integration) {
|
|
2111
|
+
return !!(
|
|
2112
|
+
integration &&
|
|
2113
|
+
typeof integration.setup_instructions === 'object' &&
|
|
2114
|
+
integration.setup_instructions !== null
|
|
2115
|
+
);
|
|
2116
|
+
}
|
|
1900
2117
|
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
2118
|
+
// Build a single display-only checklist row for one integration using safe DOM
|
|
2119
|
+
// APIs (createElement + textContent) — never innerHTML with server data. Shows
|
|
2120
|
+
// the label, a configured/unconfigured chip, required_for text, and a
|
|
2121
|
+
// setup-detail deep link. Renders no buttons, credential values, or inputs.
|
|
2122
|
+
function renderIntegrationChecklistRow(integration, projectId) {
|
|
2123
|
+
const row = document.createElement('div');
|
|
2124
|
+
row.className = 'status-field-row install-integration-row';
|
|
2125
|
+
row.setAttribute('role', 'listitem');
|
|
2126
|
+
if (integration.id) {
|
|
2127
|
+
row.setAttribute('data-integration-id', integration.id);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
const configured = !!integration.is_configured;
|
|
2131
|
+
|
|
2132
|
+
const chip = document.createElement('span');
|
|
2133
|
+
chip.className = `status-chip ${configured ? 'status-set' : 'status-unset'}`;
|
|
2134
|
+
chip.textContent = configured ? 'Configured' : 'Unconfigured';
|
|
2135
|
+
row.appendChild(chip);
|
|
2136
|
+
|
|
2137
|
+
const label = document.createElement('span');
|
|
2138
|
+
label.className = 'status-field-name install-integration-label';
|
|
2139
|
+
label.textContent = integration.label || integration.id || 'Integration';
|
|
2140
|
+
row.appendChild(label);
|
|
2141
|
+
|
|
2142
|
+
const requiredForText = formatRequiredForText(integration.required_for);
|
|
2143
|
+
if (requiredForText) {
|
|
2144
|
+
const required = document.createElement('span');
|
|
2145
|
+
required.className = 'status-field-value install-integration-required';
|
|
2146
|
+
required.textContent = `Required for: ${requiredForText}`;
|
|
2147
|
+
row.appendChild(required);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
const link = document.createElement('a');
|
|
2151
|
+
link.className = 'install-integration-link';
|
|
2152
|
+
link.href = buildSetupDetailHref(projectId, getIntegrationSetupFragment(integration));
|
|
2153
|
+
const hasInstructions = hasSetupInstructions(integration);
|
|
2154
|
+
link.textContent = hasInstructions ? 'View setup instructions' : 'Open Setup Detail';
|
|
2155
|
+
const labelText = integration.label || integration.id || 'this integration';
|
|
2156
|
+
link.setAttribute(
|
|
2157
|
+
'aria-label',
|
|
2158
|
+
`${hasInstructions ? 'View setup instructions' : 'Open Setup Detail'} for ${labelText}`
|
|
2159
|
+
);
|
|
2160
|
+
row.appendChild(link);
|
|
1907
2161
|
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
link.addEventListener('click', () => {
|
|
1911
|
-
link.classList.add('hidden');
|
|
1912
|
-
const container = link.closest('.reanalyze-container');
|
|
1913
|
-
const optionsWrapper = container?.querySelector('.lr-reanalyze-options');
|
|
1914
|
-
optionsWrapper?.classList.remove('hidden');
|
|
1915
|
-
const defaultRadio = optionsWrapper?.querySelector('input[value="auto_complete"]');
|
|
1916
|
-
defaultRadio?.focus();
|
|
1917
|
-
});
|
|
1918
|
-
});
|
|
2162
|
+
return row;
|
|
2163
|
+
}
|
|
1919
2164
|
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
});
|
|
1926
|
-
});
|
|
2165
|
+
// Render the full integrations checklist into container. Absent-safe: no-ops on
|
|
2166
|
+
// a missing container, a non-array/empty integrations value, or when no valid
|
|
2167
|
+
// object items remain. Clears the container first so refreshes never duplicate.
|
|
2168
|
+
function renderIntegrationChecklist(integrations, container, projectId) {
|
|
2169
|
+
if (!container) return;
|
|
1927
2170
|
|
|
1928
|
-
|
|
1929
|
-
document.querySelectorAll('.lr-reanalyze-cancel-btn').forEach((btn) => {
|
|
1930
|
-
btn.addEventListener('click', (e) => {
|
|
1931
|
-
e.preventDefault();
|
|
1932
|
-
const container = btn.closest('.reanalyze-container');
|
|
1933
|
-
const optionsWrapper = container?.querySelector('.lr-reanalyze-options');
|
|
1934
|
-
const reanalyzeLink = container?.querySelector('.lr-reanalyze-link');
|
|
1935
|
-
optionsWrapper?.classList.add('hidden');
|
|
1936
|
-
reanalyzeLink?.classList.remove('hidden');
|
|
1937
|
-
// Reset radio selection to auto_complete
|
|
1938
|
-
const defaultRadio = optionsWrapper?.querySelector('input[value="auto_complete"]');
|
|
1939
|
-
if (defaultRadio) defaultRadio.checked = true;
|
|
1940
|
-
reanalyzeLink?.focus();
|
|
1941
|
-
});
|
|
1942
|
-
});
|
|
2171
|
+
container.innerHTML = '';
|
|
1943
2172
|
|
|
1944
|
-
|
|
1945
|
-
document.querySelectorAll('.lr-accept-draft-btn').forEach((btn) => {
|
|
1946
|
-
btn.addEventListener('click', () => handleAcceptDraft(btn.dataset.analysisType));
|
|
1947
|
-
});
|
|
1948
|
-
document.querySelectorAll('.lr-discard-draft-btn').forEach((btn) => {
|
|
1949
|
-
btn.addEventListener('click', () => handleDiscardDraft(btn.dataset.analysisType));
|
|
1950
|
-
});
|
|
2173
|
+
if (!Array.isArray(integrations) || integrations.length === 0) return;
|
|
1951
2174
|
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
2175
|
+
const validItems = integrations.filter(
|
|
2176
|
+
item => item && typeof item === 'object'
|
|
2177
|
+
);
|
|
2178
|
+
if (validItems.length === 0) return;
|
|
2179
|
+
|
|
2180
|
+
const wrapper = document.createElement('div');
|
|
2181
|
+
wrapper.className = 'install-integrations-wrapper';
|
|
2182
|
+
|
|
2183
|
+
const title = document.createElement('h3');
|
|
2184
|
+
title.className = 'install-integrations-title';
|
|
2185
|
+
title.textContent = 'Integration checklist';
|
|
2186
|
+
wrapper.appendChild(title);
|
|
2187
|
+
|
|
2188
|
+
const note = document.createElement('p');
|
|
2189
|
+
note.className = 'install-integrations-note';
|
|
2190
|
+
note.textContent =
|
|
2191
|
+
'Presence-only status — no credential values are shown here. ' +
|
|
2192
|
+
'Use Setup Detail to add or update integration credentials.';
|
|
2193
|
+
wrapper.appendChild(note);
|
|
2194
|
+
|
|
2195
|
+
const list = document.createElement('div');
|
|
2196
|
+
list.className = 'install-integrations-list';
|
|
2197
|
+
list.setAttribute('role', 'list');
|
|
2198
|
+
|
|
2199
|
+
validItems.forEach(item => {
|
|
2200
|
+
list.appendChild(renderIntegrationChecklistRow(item, projectId));
|
|
1955
2201
|
});
|
|
2202
|
+
|
|
2203
|
+
wrapper.appendChild(list);
|
|
2204
|
+
container.appendChild(wrapper);
|
|
1956
2205
|
}
|
|
1957
2206
|
|
|
1958
|
-
function
|
|
1959
|
-
const
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
2207
|
+
function renderInstallStatus(installStatus, projectId) {
|
|
2208
|
+
const summaryEl = document.getElementById('install-status-summary');
|
|
2209
|
+
const groupsEl = document.getElementById('install-status-groups');
|
|
2210
|
+
if (!summaryEl || !groupsEl) return;
|
|
2211
|
+
|
|
2212
|
+
// The integrations container is optional so the function stays defensive if
|
|
2213
|
+
// the template does not (yet) include it.
|
|
2214
|
+
const integrationsEl = document.getElementById('install-integrations');
|
|
2215
|
+
|
|
2216
|
+
// Clear all render targets up front so a refresh (e.g. after a GitHub
|
|
2217
|
+
// connection completes) never duplicates prior content.
|
|
2218
|
+
summaryEl.innerHTML = '';
|
|
2219
|
+
groupsEl.innerHTML = '';
|
|
2220
|
+
if (integrationsEl) integrationsEl.innerHTML = '';
|
|
2221
|
+
|
|
2222
|
+
if (!installStatus || !installStatus.available) {
|
|
2223
|
+
summaryEl.innerHTML = '<p class="status-empty">No configuration found for this project.</p>';
|
|
2224
|
+
return;
|
|
1963
2225
|
}
|
|
1964
|
-
}
|
|
1965
2226
|
|
|
1966
|
-
|
|
1967
|
-
|
|
2227
|
+
const { summary, groups, integrations } = installStatus;
|
|
2228
|
+
if (summary) {
|
|
2229
|
+
const row = document.createElement('div');
|
|
2230
|
+
row.className = 'status-summary';
|
|
1968
2231
|
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2232
|
+
const setChip = document.createElement('span');
|
|
2233
|
+
setChip.className = 'status-chip status-set';
|
|
2234
|
+
setChip.textContent = `${summary.set} Set`;
|
|
1972
2235
|
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
(0,_optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__.pollUntil)(statusUrl, (data) => data != null && data.learning_in_progress !== true, {
|
|
1977
|
-
intervalMs: LEARNING_POLL_INTERVAL_MS,
|
|
1978
|
-
timeoutMs: LEARNING_POLL_TIMEOUT_MS,
|
|
1979
|
-
continueOnError: true,
|
|
1980
|
-
signal: controller.signal,
|
|
1981
|
-
onTick: (data) => {
|
|
1982
|
-
updateLatestLearnRepositoryStatus(data);
|
|
1983
|
-
renderUI(data);
|
|
1984
|
-
},
|
|
1985
|
-
})
|
|
1986
|
-
.then(() => {
|
|
1987
|
-
activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
|
|
1988
|
-
showStatusMessage('Learning analysis complete. Review any generated clarifications below.', 'success');
|
|
1989
|
-
})
|
|
1990
|
-
.catch((error) => {
|
|
1991
|
-
if (error && error.name === 'AbortError') return;
|
|
1992
|
-
activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
|
|
1993
|
-
if (error instanceof _optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__.PollTimeoutError) {
|
|
1994
|
-
showStatusMessage(
|
|
1995
|
-
'Learning is taking longer than expected. Please refresh the page to see the latest status.',
|
|
1996
|
-
'error'
|
|
1997
|
-
);
|
|
1998
|
-
} else {
|
|
1999
|
-
console.error('Global learning poll error:', error);
|
|
2000
|
-
}
|
|
2001
|
-
});
|
|
2002
|
-
}
|
|
2236
|
+
const unsetChip = document.createElement('span');
|
|
2237
|
+
unsetChip.className = 'status-chip status-unset';
|
|
2238
|
+
unsetChip.textContent = `${summary.unset} Unset`;
|
|
2003
2239
|
|
|
2004
|
-
|
|
2005
|
-
|
|
2240
|
+
const total = document.createElement('span');
|
|
2241
|
+
total.className = 'status-total';
|
|
2242
|
+
total.textContent = `of ${summary.total} fields`;
|
|
2006
2243
|
|
|
2007
|
-
|
|
2244
|
+
row.appendChild(setChip);
|
|
2245
|
+
row.appendChild(unsetChip);
|
|
2246
|
+
row.appendChild(total);
|
|
2247
|
+
summaryEl.appendChild(row);
|
|
2248
|
+
}
|
|
2008
2249
|
|
|
2009
|
-
|
|
2250
|
+
// Render the integrations checklist before the config-field groups so it
|
|
2251
|
+
// still appears when integration status is present but groups are empty.
|
|
2252
|
+
renderIntegrationChecklist(integrations, integrationsEl, projectId);
|
|
2010
2253
|
|
|
2011
|
-
if (!
|
|
2254
|
+
if (!groups || groups.length === 0) {
|
|
2255
|
+
groupsEl.innerHTML = '<p class="status-empty">No configuration fields found.</p>';
|
|
2012
2256
|
return;
|
|
2013
2257
|
}
|
|
2014
2258
|
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2259
|
+
groups.forEach(group => {
|
|
2260
|
+
if (!group.fields || group.fields.length === 0) return;
|
|
2261
|
+
const section = document.createElement('div');
|
|
2262
|
+
section.className = 'status-group';
|
|
2018
2263
|
|
|
2019
|
-
|
|
2020
|
-
|
|
2264
|
+
const heading = document.createElement('h3');
|
|
2265
|
+
heading.className = 'status-group-title';
|
|
2266
|
+
heading.textContent = group.title || group.key;
|
|
2267
|
+
section.appendChild(heading);
|
|
2021
2268
|
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2269
|
+
group.fields.forEach(field => {
|
|
2270
|
+
const row = document.createElement('div');
|
|
2271
|
+
row.className = 'status-field-row';
|
|
2272
|
+
|
|
2273
|
+
const chip = document.createElement('span');
|
|
2274
|
+
chip.className = `status-chip ${field.is_set ? 'status-set' : 'status-unset'}`;
|
|
2275
|
+
chip.textContent = field.is_set ? 'Set' : 'Unset';
|
|
2276
|
+
|
|
2277
|
+
const label = document.createElement('span');
|
|
2278
|
+
label.className = 'status-field-name';
|
|
2279
|
+
label.textContent = field.field_name;
|
|
2280
|
+
|
|
2281
|
+
row.appendChild(chip);
|
|
2282
|
+
row.appendChild(label);
|
|
2283
|
+
|
|
2284
|
+
if (field.current_value !== null && field.current_value !== undefined) {
|
|
2285
|
+
const val = document.createElement('span');
|
|
2286
|
+
val.className = 'status-field-value';
|
|
2287
|
+
const display =
|
|
2288
|
+
typeof field.current_value === 'object'
|
|
2289
|
+
? JSON.stringify(field.current_value)
|
|
2290
|
+
: String(field.current_value);
|
|
2291
|
+
val.textContent = display;
|
|
2292
|
+
row.appendChild(val);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
section.appendChild(row);
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
groupsEl.appendChild(section);
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// ============================================================
|
|
2303
|
+
// Data loading
|
|
2304
|
+
// ============================================================
|
|
2305
|
+
|
|
2306
|
+
async function loadInstallInstructions(projectId) {
|
|
2307
|
+
const loadingEl = document.getElementById('get-started-loading');
|
|
2308
|
+
const errorEl = document.getElementById('get-started-error');
|
|
2309
|
+
const contentEl = document.getElementById('get-started-content');
|
|
2310
|
+
|
|
2311
|
+
if (loadingEl) loadingEl.classList.remove('hidden');
|
|
2312
|
+
if (errorEl) errorEl.classList.add('hidden');
|
|
2313
|
+
if (contentEl) contentEl.classList.add('hidden');
|
|
2314
|
+
|
|
2315
|
+
try {
|
|
2316
|
+
const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.apiCall)(
|
|
2317
|
+
`/setup/install-instructions?projectId=${encodeURIComponent(projectId)}`,
|
|
2318
|
+
'GET'
|
|
2319
|
+
);
|
|
2320
|
+
|
|
2321
|
+
// apiCall returns null on centralized 401 redirect
|
|
2322
|
+
if (!data) return;
|
|
2323
|
+
|
|
2324
|
+
if (loadingEl) loadingEl.classList.add('hidden');
|
|
2325
|
+
|
|
2326
|
+
// Defensive sync: keep the container's repo dataset authoritative from
|
|
2327
|
+
// the install-instructions payload so the install-key click handler
|
|
2328
|
+
// reads the correct repo_name even if the server attribute was empty.
|
|
2329
|
+
const containerEl = document.getElementById('get-started-container');
|
|
2330
|
+
if (containerEl && data.repo_name) {
|
|
2331
|
+
containerEl.dataset.repoName = data.repo_name;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
const repoLabel = document.getElementById('get-started-repo-label');
|
|
2335
|
+
if (repoLabel) {
|
|
2336
|
+
repoLabel.textContent = `Project: ${data.repo_name}`;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
renderSnippetTabs(data.snippets || []);
|
|
2340
|
+
initializeHandoffCopy();
|
|
2341
|
+
renderInstallStatus(data.install_status, projectId);
|
|
2342
|
+
|
|
2343
|
+
if (contentEl) contentEl.classList.remove('hidden');
|
|
2344
|
+
|
|
2345
|
+
// Render any GitHub connection return-state now that the repo dataset is
|
|
2346
|
+
// synchronized from the install-instructions payload (BAPI-506).
|
|
2347
|
+
renderGitHubConnectionReturnState();
|
|
2348
|
+
} catch (error) {
|
|
2349
|
+
if (loadingEl) loadingEl.classList.add('hidden');
|
|
2350
|
+
if (contentEl) contentEl.classList.add('hidden');
|
|
2351
|
+
|
|
2352
|
+
const msgEl = document.getElementById('get-started-error-msg');
|
|
2353
|
+
if (msgEl) {
|
|
2354
|
+
msgEl.textContent = (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.getApiErrorMessage)(error, 'Failed to load install instructions.');
|
|
2355
|
+
}
|
|
2356
|
+
if (errorEl) errorEl.classList.remove('hidden');
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// ============================================================
|
|
2361
|
+
// Install-key generation
|
|
2362
|
+
// ============================================================
|
|
2363
|
+
|
|
2364
|
+
// Replace every onboarding snippet's data-bapi-api-key placeholder with the
|
|
2365
|
+
// minted key, scoped strictly to the onboarding container. Returns the number
|
|
2366
|
+
// of updated elements so callers can detect an unexpected empty-snippet state.
|
|
2367
|
+
function injectInstallKeyIntoSnippets(apiKey) {
|
|
2368
|
+
const slots = document.querySelectorAll('#get-started-container [data-bapi-api-key]');
|
|
2369
|
+
slots.forEach(el => {
|
|
2370
|
+
el.textContent = apiKey;
|
|
2371
|
+
el.classList.add('bapi-api-key-slot-filled');
|
|
2372
|
+
});
|
|
2373
|
+
return slots.length;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// Toggle the loading state on the generate button. When loading ends without
|
|
2377
|
+
// success the caller passes loading=false to restore the idle affordance.
|
|
2378
|
+
function setInstallKeyLoading(loading) {
|
|
2379
|
+
const btn = document.getElementById('generate-install-key-btn');
|
|
2380
|
+
const panel = document.getElementById('install-key-panel');
|
|
2381
|
+
const errorEl = document.getElementById('install-key-error');
|
|
2382
|
+
if (loading) {
|
|
2383
|
+
if (errorEl) {
|
|
2384
|
+
errorEl.textContent = '';
|
|
2385
|
+
errorEl.classList.add('hidden');
|
|
2386
|
+
}
|
|
2387
|
+
if (btn) {
|
|
2388
|
+
btn.disabled = true;
|
|
2389
|
+
btn.textContent = 'Generating…';
|
|
2390
|
+
}
|
|
2391
|
+
if (panel) panel.setAttribute('aria-busy', 'true');
|
|
2392
|
+
} else {
|
|
2393
|
+
if (btn) {
|
|
2394
|
+
btn.disabled = false;
|
|
2395
|
+
btn.textContent = 'Generate install key';
|
|
2396
|
+
}
|
|
2397
|
+
if (panel) panel.removeAttribute('aria-busy');
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// Write a brief, non-leaking failure message near the action. Never surface raw
|
|
2402
|
+
// server error details, request body fields, or any plaintext key.
|
|
2403
|
+
function showInstallKeyError(message) {
|
|
2404
|
+
const errorEl = document.getElementById('install-key-error');
|
|
2405
|
+
if (!errorEl) return;
|
|
2406
|
+
errorEl.textContent = message || 'Could not generate the install key. Please try again.';
|
|
2407
|
+
errorEl.classList.remove('hidden');
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// Mint an admin install key on explicit click via the existing POST /setup/keys
|
|
2411
|
+
// route, then reveal it once and inject it into every snippet.
|
|
2412
|
+
async function handleGenerateInstallKeyClick() {
|
|
2413
|
+
const container = document.getElementById('get-started-container');
|
|
2414
|
+
if (!container) return;
|
|
2415
|
+
|
|
2416
|
+
// Prevent silent repeat minting within the same page lifetime. By design
|
|
2417
|
+
// this guard is per-page-load only: a reload intentionally allows minting a
|
|
2418
|
+
// fresh on-demand key (BAPI-383). Unused keys are revocable from the
|
|
2419
|
+
// Security page, so we accept this over reusing/auto-revoking prior keys.
|
|
2420
|
+
if (generatedInstallKey) return;
|
|
2421
|
+
|
|
2422
|
+
const repoName = container.dataset.repoName;
|
|
2423
|
+
if (!repoName) {
|
|
2424
|
+
showInstallKeyError('Could not generate the install key. Please try again.');
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
setInstallKeyLoading(true);
|
|
2429
|
+
|
|
2430
|
+
const payload = {
|
|
2431
|
+
email: deriveInstallKeyEmail(repoName),
|
|
2432
|
+
repo_name: repoName,
|
|
2433
|
+
name: INSTALL_KEY_NAME,
|
|
2434
|
+
role: INSTALL_KEY_ROLE,
|
|
2435
|
+
};
|
|
2436
|
+
|
|
2437
|
+
let response;
|
|
2438
|
+
try {
|
|
2439
|
+
response = await fetch('/setup/keys', {
|
|
2440
|
+
method: 'POST',
|
|
2441
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2442
|
+
body: JSON.stringify(payload),
|
|
2443
|
+
});
|
|
2444
|
+
} catch (_) {
|
|
2445
|
+
setInstallKeyLoading(false);
|
|
2446
|
+
showInstallKeyError('Could not generate the install key. Please try again.');
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// Only a 201 Created is success. Do not parse/display raw server details.
|
|
2451
|
+
if (response.status !== 201) {
|
|
2452
|
+
setInstallKeyLoading(false);
|
|
2453
|
+
showInstallKeyError('Could not generate the install key. Please try again.');
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
let data;
|
|
2458
|
+
try {
|
|
2459
|
+
data = await response.json();
|
|
2460
|
+
} catch (_) {
|
|
2461
|
+
setInstallKeyLoading(false);
|
|
2462
|
+
showInstallKeyError('Could not generate the install key. Please try again.');
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
const apiKey = data && data.api_key;
|
|
2467
|
+
if (!apiKey) {
|
|
2468
|
+
setInstallKeyLoading(false);
|
|
2469
|
+
showInstallKeyError('Could not generate the install key. Please try again.');
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
generatedInstallKey = apiKey;
|
|
2474
|
+
|
|
2475
|
+
const keyInput = document.getElementById('generated-install-key');
|
|
2476
|
+
if (keyInput) keyInput.value = apiKey;
|
|
2477
|
+
|
|
2478
|
+
const resultEl = document.getElementById('install-key-result');
|
|
2479
|
+
if (resultEl) resultEl.classList.remove('hidden');
|
|
2480
|
+
|
|
2481
|
+
injectInstallKeyIntoSnippets(apiKey);
|
|
2482
|
+
|
|
2483
|
+
const statusEl = document.getElementById('install-key-status');
|
|
2484
|
+
if (statusEl) statusEl.textContent = 'Admin install key generated and inserted into the snippets.';
|
|
2485
|
+
|
|
2486
|
+
// Permanently disable the button after success to avoid repeated minting.
|
|
2487
|
+
const btn = document.getElementById('generate-install-key-btn');
|
|
2488
|
+
const panel = document.getElementById('install-key-panel');
|
|
2489
|
+
if (btn) {
|
|
2490
|
+
btn.disabled = true;
|
|
2491
|
+
btn.textContent = 'Install key generated';
|
|
2492
|
+
}
|
|
2493
|
+
if (panel) panel.removeAttribute('aria-busy');
|
|
2494
|
+
|
|
2495
|
+
// Move focus to the revealed credential controls for keyboard/screen-reader users.
|
|
2496
|
+
const copyBtn = document.getElementById('copy-install-key-btn');
|
|
2497
|
+
if (copyBtn) copyBtn.focus();
|
|
2498
|
+
else if (keyInput) keyInput.focus();
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// Page-scoped, click-driven initializer. Binds the generate and copy buttons.
|
|
2502
|
+
// Returns without side effects if the panel is absent. Does NOT mint on load.
|
|
2503
|
+
function initializeInstallKeyGeneration(container) {
|
|
2504
|
+
if (!container) return;
|
|
2505
|
+
|
|
2506
|
+
const generateBtn = document.getElementById('generate-install-key-btn');
|
|
2507
|
+
if (!generateBtn) return;
|
|
2508
|
+
|
|
2509
|
+
generateBtn.addEventListener('click', handleGenerateInstallKeyClick);
|
|
2510
|
+
|
|
2511
|
+
const copyBtn = document.getElementById('copy-install-key-btn');
|
|
2512
|
+
const keyInput = document.getElementById('generated-install-key');
|
|
2513
|
+
const copyStatus = document.getElementById('copy-install-key-status');
|
|
2514
|
+
if (copyBtn && keyInput) {
|
|
2515
|
+
copyBtn.addEventListener('click', async () => {
|
|
2516
|
+
const value = keyInput.value;
|
|
2517
|
+
if (!value) return;
|
|
2518
|
+
try {
|
|
2519
|
+
await copyTextToClipboard(value);
|
|
2520
|
+
if (copyStatus) copyStatus.textContent = 'Copied!';
|
|
2521
|
+
copyBtn.textContent = 'Copied!';
|
|
2522
|
+
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
|
|
2523
|
+
} catch (_) {
|
|
2524
|
+
if (copyStatus) copyStatus.textContent = 'Copy failed';
|
|
2525
|
+
}
|
|
2526
|
+
});
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// ============================================================
|
|
2531
|
+
// Entry point
|
|
2532
|
+
// ============================================================
|
|
2533
|
+
|
|
2534
|
+
// ============================================================
|
|
2535
|
+
// GitHub App connect flow (BAPI-506)
|
|
2536
|
+
// ============================================================
|
|
2537
|
+
|
|
2538
|
+
function getGitHubRepoName() {
|
|
2539
|
+
const container = document.getElementById('get-started-container');
|
|
2540
|
+
const repoName = container && container.dataset ? container.dataset.repoName : '';
|
|
2541
|
+
return (repoName || '').trim();
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function setGitHubConnectLoading(loading) {
|
|
2545
|
+
const btn = document.getElementById('connect-github-btn');
|
|
2546
|
+
const section = document.getElementById('github-connect-section');
|
|
2547
|
+
const status = document.getElementById('github-connect-status');
|
|
2548
|
+
if (btn) {
|
|
2549
|
+
btn.disabled = loading;
|
|
2550
|
+
btn.textContent = loading
|
|
2551
|
+
? GITHUB_CONNECT_MESSAGES.connecting
|
|
2552
|
+
: GITHUB_CONNECT_MESSAGES.connectButton;
|
|
2553
|
+
}
|
|
2554
|
+
if (section) section.setAttribute('aria-busy', loading ? 'true' : 'false');
|
|
2555
|
+
if (status) status.textContent = loading ? GITHUB_CONNECT_MESSAGES.connecting : '';
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
function showGitHubConnectError(message) {
|
|
2559
|
+
const errorEl = document.getElementById('github-connect-error');
|
|
2560
|
+
if (errorEl) {
|
|
2561
|
+
errorEl.textContent = message;
|
|
2562
|
+
errorEl.classList.remove('hidden');
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
function clearGitHubConnectionPanels() {
|
|
2567
|
+
['github-connect-error', 'github-connect-result', 'github-repo-picker-panel'].forEach((id) => {
|
|
2568
|
+
const el = document.getElementById(id);
|
|
2569
|
+
if (el) {
|
|
2570
|
+
el.classList.add('hidden');
|
|
2571
|
+
if (id !== 'github-connect-error') el.innerHTML = '';
|
|
2572
|
+
}
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
function renderGitHubConnectedState(repoDisplayName) {
|
|
2577
|
+
clearGitHubConnectionPanels();
|
|
2578
|
+
const resultEl = document.getElementById('github-connect-result');
|
|
2579
|
+
if (!resultEl) return;
|
|
2580
|
+
const title = document.createElement('p');
|
|
2581
|
+
title.className = 'github-connect-result-title';
|
|
2582
|
+
title.textContent = GITHUB_CONNECT_MESSAGES.connectedTitle || 'GitHub connected';
|
|
2583
|
+
const body = document.createElement('p');
|
|
2584
|
+
body.textContent = repoDisplayName
|
|
2585
|
+
? `Bridge verified the installation and linked ${repoDisplayName} automatically.`
|
|
2586
|
+
: 'Bridge verified the installation and linked your repository automatically.';
|
|
2587
|
+
resultEl.classList.remove('github-connect-result--warning', 'github-connect-result--error');
|
|
2588
|
+
resultEl.classList.add('github-connect-result--success');
|
|
2589
|
+
resultEl.appendChild(title);
|
|
2590
|
+
resultEl.appendChild(body);
|
|
2591
|
+
resultEl.classList.remove('hidden');
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
function renderGitHubFailureState(status) {
|
|
2595
|
+
// The manual fallback link is always present in the card markup; failure
|
|
2596
|
+
// states just surface sanitized inline copy alongside it.
|
|
2597
|
+
const message = GITHUB_CONNECT_MESSAGES[status] || GITHUB_CONNECT_MESSAGES.connectionError;
|
|
2598
|
+
showGitHubConnectError(message);
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
async function handleConnectGitHubClick() {
|
|
2602
|
+
clearGitHubConnectionPanels();
|
|
2603
|
+
const repoName = getGitHubRepoName();
|
|
2604
|
+
if (!repoName) {
|
|
2605
|
+
showGitHubConnectError(GITHUB_CONNECT_MESSAGES.missingRepo);
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
setGitHubConnectLoading(true);
|
|
2610
|
+
try {
|
|
2611
|
+
const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.apiCall)('/setup/github/connection-code', 'POST', { repo_name: repoName });
|
|
2612
|
+
// apiCall returns null on centralized 401 redirect (navigating to login).
|
|
2613
|
+
if (!data) return;
|
|
2614
|
+
if (!data.install_url) {
|
|
2615
|
+
showGitHubConnectError(GITHUB_CONNECT_MESSAGES.connectionError);
|
|
2616
|
+
setGitHubConnectLoading(false);
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
// Navigate the browser to the GitHub App install URL.
|
|
2620
|
+
window.location.assign(data.install_url);
|
|
2621
|
+
} catch (error) {
|
|
2622
|
+
showGitHubConnectError(GITHUB_CONNECT_MESSAGES.connectionError);
|
|
2623
|
+
setGitHubConnectLoading(false);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function renderGitHubRepoPicker(candidates) {
|
|
2628
|
+
const panel = document.getElementById('github-repo-picker-panel');
|
|
2629
|
+
if (!panel) return;
|
|
2630
|
+
panel.innerHTML = '';
|
|
2631
|
+
|
|
2632
|
+
const prompt = document.createElement('p');
|
|
2633
|
+
prompt.textContent = GITHUB_CONNECT_MESSAGES.pickerPrompt;
|
|
2634
|
+
panel.appendChild(prompt);
|
|
2635
|
+
|
|
2636
|
+
const list = document.createElement('div');
|
|
2637
|
+
list.className = 'github-repo-picker-list';
|
|
2638
|
+
|
|
2639
|
+
candidates.forEach((candidate) => {
|
|
2640
|
+
const option = document.createElement('button');
|
|
2641
|
+
option.type = 'button';
|
|
2642
|
+
option.className = 'github-repo-picker-option btn btn-secondary';
|
|
2643
|
+
option.setAttribute('data-testid', 'github-repo-picker-option');
|
|
2644
|
+
option.setAttribute('data-github-repository-id', candidate.github_repository_id);
|
|
2645
|
+
|
|
2646
|
+
const nameSpan = document.createElement('span');
|
|
2647
|
+
nameSpan.className = 'github-repo-picker-option-name';
|
|
2648
|
+
nameSpan.textContent = candidate.github_repo_full_name || candidate.github_repo_name;
|
|
2649
|
+
option.appendChild(nameSpan);
|
|
2650
|
+
|
|
2651
|
+
if (candidate.owner) {
|
|
2652
|
+
const ownerSpan = document.createElement('span');
|
|
2653
|
+
ownerSpan.className = 'github-repo-picker-option-owner';
|
|
2654
|
+
ownerSpan.textContent = candidate.owner;
|
|
2655
|
+
option.appendChild(ownerSpan);
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
option.addEventListener('click', () => handleGitHubRepoSelection(candidate));
|
|
2659
|
+
list.appendChild(option);
|
|
2660
|
+
});
|
|
2661
|
+
|
|
2662
|
+
panel.appendChild(list);
|
|
2663
|
+
panel.classList.remove('hidden');
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
async function loadGitHubPickerCandidates() {
|
|
2667
|
+
const panel = document.getElementById('github-repo-picker-panel');
|
|
2668
|
+
const params = new URLSearchParams(window.location.search);
|
|
2669
|
+
const state = params.get('state');
|
|
2670
|
+
const installationId = params.get('installationId');
|
|
2671
|
+
const repoName = getGitHubRepoName();
|
|
2672
|
+
|
|
2673
|
+
if (!state || !installationId || !repoName) {
|
|
2674
|
+
renderGitHubFailureState('unresolved');
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
if (panel) {
|
|
2679
|
+
panel.innerHTML = `<p>${GITHUB_CONNECT_MESSAGES.pickerLoading}</p>`;
|
|
2680
|
+
panel.classList.remove('hidden');
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
try {
|
|
2684
|
+
const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.apiCall)('/setup/github/repository-candidates', 'POST', {
|
|
2685
|
+
repo_name: repoName,
|
|
2686
|
+
state,
|
|
2687
|
+
installation_id: installationId,
|
|
2688
|
+
});
|
|
2689
|
+
if (!data) return;
|
|
2690
|
+
const candidates = (data && data.candidates) || [];
|
|
2691
|
+
if (!candidates.length) {
|
|
2692
|
+
if (panel) panel.innerHTML = `<p>${GITHUB_CONNECT_MESSAGES.pickerEmpty}</p>`;
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2695
|
+
renderGitHubRepoPicker(candidates);
|
|
2696
|
+
} catch (error) {
|
|
2697
|
+
if (panel) panel.innerHTML = `<p>${GITHUB_CONNECT_MESSAGES.pickerEmpty}</p>`;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
async function handleGitHubRepoSelection(candidate) {
|
|
2702
|
+
const params = new URLSearchParams(window.location.search);
|
|
2703
|
+
const state = params.get('state');
|
|
2704
|
+
const installationId = params.get('installationId');
|
|
2705
|
+
const repoName = getGitHubRepoName();
|
|
2706
|
+
if (!state || !installationId || !repoName) {
|
|
2707
|
+
renderGitHubFailureState('unresolved');
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
try {
|
|
2712
|
+
const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.apiCall)('/setup/github/select-repository', 'POST', {
|
|
2713
|
+
repo_name: repoName,
|
|
2714
|
+
state,
|
|
2715
|
+
installation_id: installationId,
|
|
2716
|
+
github_repository_id: candidate.github_repository_id,
|
|
2717
|
+
});
|
|
2718
|
+
if (!data) return;
|
|
2719
|
+
if (data.status === 'connected') {
|
|
2720
|
+
renderGitHubConnectedState(candidate.github_repo_full_name || candidate.github_repo_name);
|
|
2721
|
+
// Refresh the live config-status readout now that the repo is bound.
|
|
2722
|
+
const projectId = new URLSearchParams(window.location.search).get('projectId');
|
|
2723
|
+
if (projectId) loadInstallInstructions(projectId);
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
renderGitHubFailureState('binding-failed');
|
|
2727
|
+
} catch (error) {
|
|
2728
|
+
renderGitHubFailureState('binding-failed');
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
function renderGitHubConnectionReturnState() {
|
|
2733
|
+
const params = new URLSearchParams(window.location.search);
|
|
2734
|
+
const status = params.get('githubConnection');
|
|
2735
|
+
if (!status) return;
|
|
2736
|
+
|
|
2737
|
+
if (status === 'connected') {
|
|
2738
|
+
renderGitHubConnectedState(params.get('githubRepo'));
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
if (status === 'ambiguous') {
|
|
2742
|
+
loadGitHubPickerCandidates();
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2745
|
+
renderGitHubFailureState(status);
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
function initializeGitHubConnect() {
|
|
2749
|
+
const btn = document.getElementById('connect-github-btn');
|
|
2750
|
+
if (btn) {
|
|
2751
|
+
btn.addEventListener('click', handleConnectGitHubClick);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
function init() {
|
|
2756
|
+
const container = document.getElementById('get-started-container');
|
|
2757
|
+
if (!container) return;
|
|
2758
|
+
|
|
2759
|
+
(0,_modal_js__WEBPACK_IMPORTED_MODULE_0__.initModal)();
|
|
2760
|
+
|
|
2761
|
+
// Click-driven install-key generation; does not mint a key on page load.
|
|
2762
|
+
initializeInstallKeyGeneration(container);
|
|
2763
|
+
|
|
2764
|
+
// Wire the "Connect GitHub" launch button (BAPI-506).
|
|
2765
|
+
initializeGitHubConnect();
|
|
2766
|
+
|
|
2767
|
+
// Bind keyboard navigation once — renderSnippetTabs may run multiple times (retry),
|
|
2768
|
+
// so binding here prevents the listener from stacking on the tabList element.
|
|
2769
|
+
const tabList = document.getElementById('editor-tabs');
|
|
2770
|
+
if (tabList) {
|
|
2771
|
+
tabList.addEventListener('keydown', (e) => {
|
|
2772
|
+
const tabs = Array.from(tabList.querySelectorAll('[role="tab"]'));
|
|
2773
|
+
if (!tabs.length) return;
|
|
2774
|
+
const current = tabs.findIndex(t => t.getAttribute('aria-selected') === 'true');
|
|
2775
|
+
let next = current;
|
|
2776
|
+
if (e.key === 'ArrowRight') next = (current + 1) % tabs.length;
|
|
2777
|
+
else if (e.key === 'ArrowLeft') next = (current - 1 + tabs.length) % tabs.length;
|
|
2778
|
+
else if (e.key === 'Home') next = 0;
|
|
2779
|
+
else if (e.key === 'End') next = tabs.length - 1;
|
|
2780
|
+
else return;
|
|
2781
|
+
e.preventDefault();
|
|
2782
|
+
activateTab(next);
|
|
2783
|
+
tabs[next].focus();
|
|
2784
|
+
});
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
const projectId = getProjectIdFromUrl();
|
|
2788
|
+
|
|
2789
|
+
// Wire the setup-detail link (FAQ drawer) and footer CTA separately
|
|
2790
|
+
const setupDetailLink = document.getElementById('setup-detail-link');
|
|
2791
|
+
if (setupDetailLink && projectId) {
|
|
2792
|
+
setupDetailLink.href = buildSetupDetailHref(projectId);
|
|
2793
|
+
}
|
|
2794
|
+
const continueSetupLink = document.getElementById('continue-setup-link');
|
|
2795
|
+
if (continueSetupLink && projectId) {
|
|
2796
|
+
continueSetupLink.href = buildSetupDetailHref(projectId);
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// Initialize FAQ drawers scoped to this page
|
|
2800
|
+
(0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.initDrawers)({ root: document, drawerSelector: '#get-started-container .drawer' });
|
|
2801
|
+
|
|
2802
|
+
// Wire retry button
|
|
2803
|
+
const retryBtn = document.getElementById('get-started-retry-btn');
|
|
2804
|
+
if (retryBtn) {
|
|
2805
|
+
retryBtn.addEventListener('click', () => {
|
|
2806
|
+
if (projectId) loadInstallInstructions(projectId);
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
if (!projectId) {
|
|
2811
|
+
const loadingEl = document.getElementById('get-started-loading');
|
|
2812
|
+
const errorEl = document.getElementById('get-started-error');
|
|
2813
|
+
const msgEl = document.getElementById('get-started-error-msg');
|
|
2814
|
+
if (loadingEl) loadingEl.classList.add('hidden');
|
|
2815
|
+
if (msgEl) msgEl.textContent = 'Missing projectId — please navigate here from your project dashboard.';
|
|
2816
|
+
if (errorEl) errorEl.classList.remove('hidden');
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
loadInstallInstructions(projectId);
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
init();
|
|
2824
|
+
|
|
2825
|
+
|
|
2826
|
+
/***/ },
|
|
2827
|
+
|
|
2828
|
+
/***/ "./src/js/learn_repository.js"
|
|
2829
|
+
/*!************************************!*\
|
|
2830
|
+
!*** ./src/js/learn_repository.js ***!
|
|
2831
|
+
\************************************/
|
|
2832
|
+
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
|
2833
|
+
|
|
2834
|
+
__webpack_require__.r(__webpack_exports__);
|
|
2835
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
2836
|
+
/* harmony export */ initLearnRepository: () => (/* binding */ initLearnRepository),
|
|
2837
|
+
/* harmony export */ renderUI: () => (/* binding */ renderUI)
|
|
2838
|
+
/* harmony export */ });
|
|
2839
|
+
/* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils.js */ "./src/js/utils.js");
|
|
2840
|
+
/* harmony import */ var _modal_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./modal.js */ "./src/js/modal.js");
|
|
2841
|
+
/* harmony import */ var _optimize_context_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./optimize/context.js */ "./src/js/optimize/context.js");
|
|
2842
|
+
/* harmony import */ var _optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./optimize/status_adapters.js */ "./src/js/optimize/status_adapters.js");
|
|
2843
|
+
/* harmony import */ var _optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./optimize/polling.js */ "./src/js/optimize/polling.js");
|
|
2844
|
+
/* harmony import */ var _optimize_feedback_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./optimize/feedback.js */ "./src/js/optimize/feedback.js");
|
|
2845
|
+
/* harmony import */ var _optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./optimize/learn_repository_renderer.js */ "./src/js/optimize/learn_repository_renderer.js");
|
|
2846
|
+
/**
|
|
2847
|
+
* Learn Repository feature for the Optimize page.
|
|
2848
|
+
*
|
|
2849
|
+
* Routed through the shared Optimize plumbing (BAPI-307): canonical identity from
|
|
2850
|
+
* getOptimizeContext, status normalization via the pure adapters, declarative
|
|
2851
|
+
* per-section rendering via the renderer, polling via pollUntil, and inline
|
|
2852
|
+
* feedback via setInlineStatus. Initialization is driven by the single Optimize
|
|
2853
|
+
* page entrypoint (optimize.js) — this module no longer self-registers a
|
|
2854
|
+
* DOMContentLoaded listener. Phase 2 removed the global Begin/Resume CTA in
|
|
2855
|
+
* favor of the per-section "Run Analysis" model.
|
|
2856
|
+
*/
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
|
|
2860
|
+
|
|
2861
|
+
|
|
2862
|
+
|
|
2863
|
+
|
|
2864
|
+
|
|
2865
|
+
|
|
2866
|
+
// Active polls keyed by analysis type (per-section) plus the global learning
|
|
2867
|
+
// poll. Each value is an AbortController so a new poll can cancel the prior one.
|
|
2868
|
+
const activePolls = new Map();
|
|
2869
|
+
|
|
2870
|
+
let latestLearnRepositoryStatus = null;
|
|
2871
|
+
const GLOBAL_LEARNING_POLL_KEY = 'global-learning';
|
|
2872
|
+
const LEARNING_POLL_INTERVAL_MS = 30000;
|
|
2873
|
+
const LEARNING_POLL_TIMEOUT_MS = 600000;
|
|
2874
|
+
|
|
2875
|
+
// Canonical repository name, sourced once from the shared Optimize context. This
|
|
2876
|
+
// is the machine-readable identifier used in API URLs and POST payloads (never
|
|
2877
|
+
// the human-facing display name).
|
|
2878
|
+
let currentRepoName = null;
|
|
2879
|
+
|
|
2880
|
+
/**
|
|
2881
|
+
* Initialize the Learn Repository feature.
|
|
2882
|
+
* @param {{repoName: (string|null)}} [context] - Shared Optimize context.
|
|
2883
|
+
*/
|
|
2884
|
+
async function initLearnRepository(context = (0,_optimize_context_js__WEBPACK_IMPORTED_MODULE_2__.getOptimizeContext)()) {
|
|
2885
|
+
currentRepoName = context && context.repoName;
|
|
2886
|
+
if (!currentRepoName) {
|
|
2887
|
+
console.error('Repository name not available');
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
try {
|
|
2892
|
+
// Fetch initial status
|
|
2893
|
+
const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.apiCall)(
|
|
2894
|
+
`/optimize/learn-repository/${(0,_optimize_context_js__WEBPACK_IMPORTED_MODULE_2__.encodePathSegment)(currentRepoName)}/status`,
|
|
2895
|
+
'GET'
|
|
2896
|
+
);
|
|
2897
|
+
if (data) {
|
|
2898
|
+
renderUI(data);
|
|
2899
|
+
} else {
|
|
2900
|
+
showStatusMessage('Failed to load repository learning status', 'error');
|
|
2901
|
+
}
|
|
2902
|
+
} catch (error) {
|
|
2903
|
+
console.error('Error fetching learn repository status:', error);
|
|
2904
|
+
showStatusMessage('Error loading repository learning status', 'error');
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// Set up event listeners
|
|
2908
|
+
setupEventListeners();
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
function updateLatestLearnRepositoryStatus(data) {
|
|
2912
|
+
if (!data) return;
|
|
2913
|
+
latestLearnRepositoryStatus = data;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
/**
|
|
2917
|
+
* Render every Learn Repository section from a status payload. Each section is
|
|
2918
|
+
* normalized by the pure adapter and rendered declaratively by the renderer.
|
|
2919
|
+
* When the server reports a run in progress and no poll is active, a global poll
|
|
2920
|
+
* is started (idempotent — safe to call from re-renders inside the poll itself).
|
|
2921
|
+
* @param {Object} data
|
|
2922
|
+
*/
|
|
2923
|
+
function renderUI(data) {
|
|
2924
|
+
updateLatestLearnRepositoryStatus(data);
|
|
2925
|
+
|
|
2926
|
+
const normalized = (0,_optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__.adaptLearnRepositoryStatus)(data);
|
|
2927
|
+
|
|
2928
|
+
_optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__.ANALYSIS_TYPES.forEach((type) => {
|
|
2929
|
+
(0,_optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__.renderLearnRepositorySection)(type, data[type] || {});
|
|
2930
|
+
});
|
|
2931
|
+
|
|
2932
|
+
if (normalized.learningInProgress && !activePolls.get(GLOBAL_LEARNING_POLL_KEY)) {
|
|
2933
|
+
pollLearningStatus();
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
function setupEventListeners() {
|
|
2938
|
+
// Approve buttons for each analysis type
|
|
2939
|
+
_optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__.ANALYSIS_TYPES.forEach((type) => {
|
|
2940
|
+
const sectionId = (0,_optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__.getSectionId)(type);
|
|
2941
|
+
const approveBtn = document.getElementById(`lr-${sectionId}-approve`);
|
|
2942
|
+
if (approveBtn) {
|
|
2943
|
+
approveBtn.addEventListener('click', () => handleApprove(type));
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// Save buttons
|
|
2947
|
+
const saveBtn = document.getElementById(`lr-${sectionId}-save`);
|
|
2948
|
+
if (saveBtn) {
|
|
2949
|
+
saveBtn.addEventListener('click', () => handleSaveInstructions(type));
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2953
|
+
// Reanalyze controls — now <button>s; reveal options instead of direct-fire.
|
|
2954
|
+
document.querySelectorAll('.lr-reanalyze-link').forEach((link) => {
|
|
2955
|
+
link.addEventListener('click', () => {
|
|
2956
|
+
link.classList.add('hidden');
|
|
2957
|
+
const container = link.closest('.reanalyze-container');
|
|
2958
|
+
const optionsWrapper = container?.querySelector('.lr-reanalyze-options');
|
|
2959
|
+
optionsWrapper?.classList.remove('hidden');
|
|
2960
|
+
const defaultRadio = optionsWrapper?.querySelector('input[value="auto_complete"]');
|
|
2961
|
+
defaultRadio?.focus();
|
|
2962
|
+
});
|
|
2963
|
+
});
|
|
2964
|
+
|
|
2965
|
+
// Confirm buttons — invoke handleReanalyze
|
|
2966
|
+
document.querySelectorAll('.lr-reanalyze-confirm-btn').forEach((btn) => {
|
|
2967
|
+
btn.addEventListener('click', (e) => {
|
|
2968
|
+
e.preventDefault();
|
|
2969
|
+
handleReanalyze(btn.dataset.analysisType);
|
|
2970
|
+
});
|
|
2971
|
+
});
|
|
2972
|
+
|
|
2973
|
+
// Cancel buttons — collapse options back to initial state
|
|
2974
|
+
document.querySelectorAll('.lr-reanalyze-cancel-btn').forEach((btn) => {
|
|
2975
|
+
btn.addEventListener('click', (e) => {
|
|
2976
|
+
e.preventDefault();
|
|
2977
|
+
const container = btn.closest('.reanalyze-container');
|
|
2978
|
+
const optionsWrapper = container?.querySelector('.lr-reanalyze-options');
|
|
2979
|
+
const reanalyzeLink = container?.querySelector('.lr-reanalyze-link');
|
|
2980
|
+
optionsWrapper?.classList.add('hidden');
|
|
2981
|
+
reanalyzeLink?.classList.remove('hidden');
|
|
2982
|
+
// Reset radio selection to auto_complete
|
|
2983
|
+
const defaultRadio = optionsWrapper?.querySelector('input[value="auto_complete"]');
|
|
2984
|
+
if (defaultRadio) defaultRadio.checked = true;
|
|
2985
|
+
reanalyzeLink?.focus();
|
|
2986
|
+
});
|
|
2987
|
+
});
|
|
2988
|
+
|
|
2989
|
+
// Accept/Discard draft buttons
|
|
2990
|
+
document.querySelectorAll('.lr-accept-draft-btn').forEach((btn) => {
|
|
2991
|
+
btn.addEventListener('click', () => handleAcceptDraft(btn.dataset.analysisType));
|
|
2992
|
+
});
|
|
2993
|
+
document.querySelectorAll('.lr-discard-draft-btn').forEach((btn) => {
|
|
2994
|
+
btn.addEventListener('click', () => handleDiscardDraft(btn.dataset.analysisType));
|
|
2995
|
+
});
|
|
2996
|
+
|
|
2997
|
+
// Per-section "Run Analysis" buttons for empty sections (the single start path)
|
|
2998
|
+
document.querySelectorAll('.lr-initial-analyze-btn').forEach((btn) => {
|
|
2999
|
+
btn.addEventListener('click', () => handleInitialAnalyze(btn.dataset.analysisType));
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
function stopGlobalLearningPoll() {
|
|
3004
|
+
const controller = activePolls.get(GLOBAL_LEARNING_POLL_KEY);
|
|
3005
|
+
if (controller) {
|
|
3006
|
+
controller.abort();
|
|
3007
|
+
activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
function pollLearningStatus() {
|
|
3012
|
+
stopGlobalLearningPoll();
|
|
3013
|
+
|
|
3014
|
+
const controller = new AbortController();
|
|
3015
|
+
activePolls.set(GLOBAL_LEARNING_POLL_KEY, controller);
|
|
3016
|
+
const statusUrl = `/optimize/learn-repository/${(0,_optimize_context_js__WEBPACK_IMPORTED_MODULE_2__.encodePathSegment)(currentRepoName)}/status`;
|
|
3017
|
+
|
|
3018
|
+
// Note: a full 'all' run can take ~30 minutes, longer than
|
|
3019
|
+
// LEARNING_POLL_TIMEOUT_MS — once the timeout fires the user sees a refresh
|
|
3020
|
+
// prompt; on refresh, renderUI's learning_in_progress branch resumes polling.
|
|
3021
|
+
(0,_optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__.pollUntil)(statusUrl, (data) => data != null && data.learning_in_progress !== true, {
|
|
3022
|
+
intervalMs: LEARNING_POLL_INTERVAL_MS,
|
|
3023
|
+
timeoutMs: LEARNING_POLL_TIMEOUT_MS,
|
|
3024
|
+
continueOnError: true,
|
|
3025
|
+
signal: controller.signal,
|
|
3026
|
+
onTick: (data) => {
|
|
3027
|
+
updateLatestLearnRepositoryStatus(data);
|
|
3028
|
+
renderUI(data);
|
|
3029
|
+
},
|
|
3030
|
+
})
|
|
3031
|
+
.then(() => {
|
|
3032
|
+
activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
|
|
3033
|
+
showStatusMessage('Learning analysis complete. Review any generated clarifications below.', 'success');
|
|
3034
|
+
})
|
|
3035
|
+
.catch((error) => {
|
|
3036
|
+
if (error && error.name === 'AbortError') return;
|
|
3037
|
+
activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
|
|
3038
|
+
if (error instanceof _optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__.PollTimeoutError) {
|
|
3039
|
+
showStatusMessage(
|
|
3040
|
+
'Learning is taking longer than expected. Please refresh the page to see the latest status.',
|
|
3041
|
+
'error'
|
|
3042
|
+
);
|
|
3043
|
+
} else {
|
|
3044
|
+
console.error('Global learning poll error:', error);
|
|
3045
|
+
}
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
async function handleApprove(analysisType) {
|
|
3050
|
+
const sectionId = (0,_optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__.getSectionId)(analysisType);
|
|
3051
|
+
|
|
3052
|
+
const confirmText = 'Are you sure you want to approve these clarifications? (You will still be able to edit this later in the configuration panel if needed)';
|
|
3053
|
+
|
|
3054
|
+
const confirmed = await showConfirmationModal('Confirm Approval', confirmText);
|
|
3055
|
+
|
|
3056
|
+
if (!confirmed) {
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
const button = document.getElementById(`lr-${sectionId}-approve`);
|
|
3061
|
+
const textarea = document.getElementById(`lr-${sectionId}-textarea`);
|
|
3062
|
+
const originalText = button ? button.textContent : null;
|
|
3063
|
+
|
|
3064
|
+
// Get the edited clarifications text from the textarea
|
|
3065
|
+
const editedClarifications = textarea ? textarea.value : '';
|
|
3066
|
+
|
|
3067
|
+
try {
|
|
3068
|
+
// Disable button and show loading state
|
|
3069
|
+
if (button) {
|
|
3070
|
+
button.disabled = true;
|
|
2026
3071
|
button.textContent = 'Processing...';
|
|
2027
3072
|
}
|
|
2028
3073
|
|
|
@@ -4000,16 +5045,20 @@ __webpack_require__.r(__webpack_exports__);
|
|
|
4000
5045
|
/* harmony export */ getChangedFieldEntries: () => (/* binding */ getChangedFieldEntries),
|
|
4001
5046
|
/* harmony export */ getCiFollowupConfigCanonicalString: () => (/* binding */ getCiFollowupConfigCanonicalString),
|
|
4002
5047
|
/* harmony export */ getPayloadValueForField: () => (/* binding */ getPayloadValueForField),
|
|
5048
|
+
/* harmony export */ getReviewPolicyOverridesCanonicalString: () => (/* binding */ getReviewPolicyOverridesCanonicalString),
|
|
5049
|
+
/* harmony export */ getReviewPolicyOverridesObject: () => (/* binding */ getReviewPolicyOverridesObject),
|
|
4003
5050
|
/* harmony export */ getSelectedCiFollowupStrategy: () => (/* binding */ getSelectedCiFollowupStrategy),
|
|
4004
5051
|
/* harmony export */ getSelectedMcpSlugs: () => (/* binding */ getSelectedMcpSlugs),
|
|
4005
5052
|
/* harmony export */ getUtf8ByteLength: () => (/* binding */ getUtf8ByteLength),
|
|
4006
5053
|
/* harmony export */ handleBlurValidation: () => (/* binding */ handleBlurValidation),
|
|
4007
5054
|
/* harmony export */ handleInputClearError: () => (/* binding */ handleInputClearError),
|
|
5055
|
+
/* harmony export */ handleReviewPolicyResetAll: () => (/* binding */ handleReviewPolicyResetAll),
|
|
4008
5056
|
/* harmony export */ handleSaveProject: () => (/* binding */ handleSaveProject),
|
|
4009
5057
|
/* harmony export */ normalizeStringArrayValue: () => (/* binding */ normalizeStringArrayValue),
|
|
4010
5058
|
/* harmony export */ populateCiFollowupConfig: () => (/* binding */ populateCiFollowupConfig),
|
|
4011
5059
|
/* harmony export */ populateMcpDocSelector: () => (/* binding */ populateMcpDocSelector),
|
|
4012
5060
|
/* harmony export */ populateProjectForms: () => (/* binding */ populateProjectForms),
|
|
5061
|
+
/* harmony export */ populateReviewPolicyOverrides: () => (/* binding */ populateReviewPolicyOverrides),
|
|
4013
5062
|
/* harmony export */ rebuildSecondOpinionProviderOptions: () => (/* binding */ rebuildSecondOpinionProviderOptions),
|
|
4014
5063
|
/* harmony export */ resetProjectDetailBaseline: () => (/* binding */ resetProjectDetailBaseline),
|
|
4015
5064
|
/* harmony export */ setupConditionalVisibilityListeners: () => (/* binding */ setupConditionalVisibilityListeners),
|
|
@@ -4018,7 +5067,8 @@ __webpack_require__.r(__webpack_exports__);
|
|
|
4018
5067
|
/* harmony export */ toggleDeepResearchTimeoutVisibility: () => (/* binding */ toggleDeepResearchTimeoutVisibility),
|
|
4019
5068
|
/* harmony export */ toggleStoryPointsFieldVisibility: () => (/* binding */ toggleStoryPointsFieldVisibility),
|
|
4020
5069
|
/* harmony export */ updateCiFollowupInstructionsByteCounter: () => (/* binding */ updateCiFollowupInstructionsByteCounter),
|
|
4021
|
-
/* harmony export */ validateInput: () => (/* binding */ validateInput)
|
|
5070
|
+
/* harmony export */ validateInput: () => (/* binding */ validateInput),
|
|
5071
|
+
/* harmony export */ validateReviewPolicyOverrides: () => (/* binding */ validateReviewPolicyOverrides)
|
|
4022
5072
|
/* harmony export */ });
|
|
4023
5073
|
/* harmony import */ var _modal_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./modal.js */ "./src/js/modal.js");
|
|
4024
5074
|
/* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils.js */ "./src/js/utils.js");
|
|
@@ -4155,113 +5205,617 @@ function setCiFollowupControlValues(config) {
|
|
|
4155
5205
|
document.querySelectorAll('input[name="ci_followup_strategy"]').forEach(radio => {
|
|
4156
5206
|
radio.checked = radio.value === config.strategy;
|
|
4157
5207
|
});
|
|
4158
|
-
const maxIterationsEl = document.getElementById('ci_followup_max_iterations');
|
|
4159
|
-
if (maxIterationsEl) maxIterationsEl.value = config.max_iterations;
|
|
4160
|
-
const maxMinutesEl = document.getElementById('ci_followup_max_minutes');
|
|
4161
|
-
if (maxMinutesEl) maxMinutesEl.value = config.max_minutes;
|
|
4162
|
-
const instructionsEl = document.getElementById('ci_followup_instructions');
|
|
4163
|
-
if (instructionsEl) instructionsEl.value = config.instructions;
|
|
5208
|
+
const maxIterationsEl = document.getElementById('ci_followup_max_iterations');
|
|
5209
|
+
if (maxIterationsEl) maxIterationsEl.value = config.max_iterations;
|
|
5210
|
+
const maxMinutesEl = document.getElementById('ci_followup_max_minutes');
|
|
5211
|
+
if (maxMinutesEl) maxMinutesEl.value = config.max_minutes;
|
|
5212
|
+
const instructionsEl = document.getElementById('ci_followup_instructions');
|
|
5213
|
+
if (instructionsEl) instructionsEl.value = config.instructions;
|
|
5214
|
+
}
|
|
5215
|
+
|
|
5216
|
+
/**
|
|
5217
|
+
* Update the byte counter text and over-limit class for the ci_followup
|
|
5218
|
+
* instructions textarea.
|
|
5219
|
+
*/
|
|
5220
|
+
function updateCiFollowupInstructionsByteCounter() {
|
|
5221
|
+
const counter = document.getElementById('ci_followup_instructions_byte_count');
|
|
5222
|
+
if (!counter) return;
|
|
5223
|
+
const instructionsEl = document.getElementById('ci_followup_instructions');
|
|
5224
|
+
const byteLength = getUtf8ByteLength(instructionsEl ? instructionsEl.value : '');
|
|
5225
|
+
counter.textContent = `${byteLength}/${CI_FOLLOWUP_INSTRUCTIONS_MAX_BYTES} bytes`;
|
|
5226
|
+
counter.classList.toggle('over-limit', byteLength > CI_FOLLOWUP_INSTRUCTIONS_MAX_BYTES);
|
|
5227
|
+
}
|
|
5228
|
+
|
|
5229
|
+
/**
|
|
5230
|
+
* Apply strategy-specific state to the instructions textarea: poll_only disables
|
|
5231
|
+
* (and, for a user-initiated change, clears) instructions; custom makes them
|
|
5232
|
+
* required; fix_and_iterate makes them optional. Initial population passes
|
|
5233
|
+
* `clearInstructionsOnPollOnly: false` so MCP-authored poll_only instructions
|
|
5234
|
+
* survive page load.
|
|
5235
|
+
* @param {{clearInstructionsOnPollOnly?: boolean}} [options]
|
|
5236
|
+
*/
|
|
5237
|
+
function applyCiFollowupStrategyState(options = {}) {
|
|
5238
|
+
const { clearInstructionsOnPollOnly = true } = options;
|
|
5239
|
+
const strategy = getSelectedCiFollowupStrategy();
|
|
5240
|
+
const instructionsEl = document.getElementById('ci_followup_instructions');
|
|
5241
|
+
if (instructionsEl) {
|
|
5242
|
+
if (strategy === 'poll_only') {
|
|
5243
|
+
if (clearInstructionsOnPollOnly) instructionsEl.value = '';
|
|
5244
|
+
instructionsEl.disabled = true;
|
|
5245
|
+
instructionsEl.required = false;
|
|
5246
|
+
} else if (strategy === 'custom') {
|
|
5247
|
+
instructionsEl.disabled = false;
|
|
5248
|
+
instructionsEl.required = true;
|
|
5249
|
+
} else { // fix_and_iterate
|
|
5250
|
+
instructionsEl.disabled = false;
|
|
5251
|
+
instructionsEl.required = false;
|
|
5252
|
+
}
|
|
5253
|
+
}
|
|
5254
|
+
updateCiFollowupInstructionsByteCounter();
|
|
5255
|
+
}
|
|
5256
|
+
|
|
5257
|
+
/**
|
|
5258
|
+
* Show/hide and enable/disable the CI Follow-up inner controls based on the enable
|
|
5259
|
+
* checkbox. Off: hide via `.hidden` and disable every inner input. On: reveal and
|
|
5260
|
+
* re-enable, then apply strategy-specific instruction state (without clearing).
|
|
5261
|
+
*/
|
|
5262
|
+
function toggleCiFollowupControlsVisibility() {
|
|
5263
|
+
const enabled = document.getElementById('ci_followup_enabled');
|
|
5264
|
+
const controls = document.getElementById('ci_followup_controls');
|
|
5265
|
+
if (!controls) return;
|
|
5266
|
+
const isOn = !!(enabled && enabled.checked);
|
|
5267
|
+
const innerInputs = controls.querySelectorAll('input, textarea, select');
|
|
5268
|
+
if (isOn) {
|
|
5269
|
+
controls.classList.remove('hidden');
|
|
5270
|
+
innerInputs.forEach(el => { el.disabled = false; });
|
|
5271
|
+
applyCiFollowupStrategyState({ clearInstructionsOnPollOnly: false });
|
|
5272
|
+
} else {
|
|
5273
|
+
controls.classList.add('hidden');
|
|
5274
|
+
innerInputs.forEach(el => { el.disabled = true; });
|
|
5275
|
+
}
|
|
5276
|
+
}
|
|
5277
|
+
|
|
5278
|
+
/**
|
|
5279
|
+
* Initialize the CI Follow-up sub-section from the GET value. Accepts the raw DB
|
|
5280
|
+
* value (canonical JSON string), an already-parsed object, or null/empty/invalid.
|
|
5281
|
+
* Invalid or empty values disable the feature and reset inner controls to defaults;
|
|
5282
|
+
* a valid config enables it and populates every control, preserving stored
|
|
5283
|
+
* instructions even for poll_only so MCP-authored values are not silently altered.
|
|
5284
|
+
* @param {string|Object|null} rawValue
|
|
5285
|
+
*/
|
|
5286
|
+
function populateCiFollowupConfig(rawValue) {
|
|
5287
|
+
const enabledEl = document.getElementById('ci_followup_enabled');
|
|
5288
|
+
let config = null;
|
|
5289
|
+
if (rawValue && typeof rawValue === 'object') {
|
|
5290
|
+
config = rawValue;
|
|
5291
|
+
} else if (typeof rawValue === 'string' && rawValue.trim() !== '') {
|
|
5292
|
+
try {
|
|
5293
|
+
config = JSON.parse(rawValue);
|
|
5294
|
+
} catch (e) {
|
|
5295
|
+
config = null;
|
|
5296
|
+
}
|
|
5297
|
+
}
|
|
5298
|
+
|
|
5299
|
+
if (!config || Array.isArray(config) || typeof config !== 'object') {
|
|
5300
|
+
if (enabledEl) enabledEl.checked = false;
|
|
5301
|
+
setCiFollowupControlValues(CI_FOLLOWUP_DEFAULTS);
|
|
5302
|
+
toggleCiFollowupControlsVisibility();
|
|
5303
|
+
return;
|
|
5304
|
+
}
|
|
5305
|
+
|
|
5306
|
+
if (enabledEl) enabledEl.checked = true;
|
|
5307
|
+
setCiFollowupControlValues({
|
|
5308
|
+
strategy: config.strategy || CI_FOLLOWUP_DEFAULTS.strategy,
|
|
5309
|
+
max_iterations: config.max_iterations != null ? config.max_iterations : CI_FOLLOWUP_DEFAULTS.max_iterations,
|
|
5310
|
+
max_minutes: config.max_minutes != null ? config.max_minutes : CI_FOLLOWUP_DEFAULTS.max_minutes,
|
|
5311
|
+
instructions: typeof config.instructions === 'string' ? config.instructions : CI_FOLLOWUP_DEFAULTS.instructions,
|
|
5312
|
+
});
|
|
5313
|
+
// Reveals the controls and applies strategy state without clearing instructions.
|
|
5314
|
+
toggleCiFollowupControlsVisibility();
|
|
5315
|
+
}
|
|
5316
|
+
|
|
5317
|
+
// --- Difficulty-adaptive review policy overrides (BAPI-579; admin-only) ---
|
|
5318
|
+
// Six fixed difficulty buckets over the 1-10 scale. Each `slug` is the lowercased,
|
|
5319
|
+
// hyphenated bucket label used for the stable data-testid selectors. `range` is
|
|
5320
|
+
// display-only (boundaries are NOT editable). The bucket labels and the default
|
|
5321
|
+
// ladder below are kept byte-identical to the backend
|
|
5322
|
+
// (api/library/review_ticket_policy.py) so a stored override round-trips exactly.
|
|
5323
|
+
const REVIEW_POLICY_BUCKETS = Object.freeze([
|
|
5324
|
+
{ name: 'Very Easy', slug: 'very-easy', range: '1' },
|
|
5325
|
+
{ name: 'Easy', slug: 'easy', range: '2' },
|
|
5326
|
+
{ name: 'Easy-Moderate', slug: 'easy-moderate', range: '3-4' },
|
|
5327
|
+
{ name: 'Moderate', slug: 'moderate', range: '5' },
|
|
5328
|
+
{ name: 'Moderate-Hard', slug: 'moderate-hard', range: '6' },
|
|
5329
|
+
{ name: 'Hard', slug: 'hard', range: '7-10' },
|
|
5330
|
+
]);
|
|
5331
|
+
|
|
5332
|
+
// The two per-bucket automations and their coarse per-round tiers. `clarify` must
|
|
5333
|
+
// keep at least one round; `critique` may be empty (skipped). Max two rounds each.
|
|
5334
|
+
const REVIEW_POLICY_AUTOMATIONS = Object.freeze(['clarify', 'critique']);
|
|
5335
|
+
const REVIEW_POLICY_AUTOMATION_LABELS = Object.freeze({ clarify: 'Clarify', critique: 'Critique' });
|
|
5336
|
+
const REVIEW_POLICY_TIERS = Object.freeze(['cheap', 'basic', 'premium']);
|
|
5337
|
+
const REVIEW_POLICY_TIER_LABELS = Object.freeze({ cheap: 'Cheap', basic: 'Basic', premium: 'Premium' });
|
|
5338
|
+
const REVIEW_POLICY_MAX_ROUNDS = 2;
|
|
5339
|
+
// Tier a freshly-added round starts on before the admin chooses a value.
|
|
5340
|
+
const REVIEW_POLICY_DEFAULT_NEW_TIER = 'basic';
|
|
5341
|
+
|
|
5342
|
+
// Built-in default ladder (mirrors DEFAULT_REVIEW_POLICY_LADDER on the backend).
|
|
5343
|
+
// Each bucket is the same {clarify:[tier...], critique:[tier...]} shape a stored
|
|
5344
|
+
// override uses; an empty critique list means that automation is skipped.
|
|
5345
|
+
const DEFAULT_REVIEW_POLICY_LADDER = Object.freeze({
|
|
5346
|
+
'Very Easy': { clarify: ['basic'], critique: [] },
|
|
5347
|
+
'Easy': { clarify: ['basic', 'basic'], critique: [] },
|
|
5348
|
+
'Easy-Moderate': { clarify: ['premium', 'basic'], critique: [] },
|
|
5349
|
+
'Moderate': { clarify: ['premium', 'premium'], critique: ['basic'] },
|
|
5350
|
+
'Moderate-Hard': { clarify: ['premium'], critique: ['premium'] },
|
|
5351
|
+
'Hard': { clarify: ['premium', 'premium'], critique: ['premium', 'premium'] },
|
|
5352
|
+
});
|
|
5353
|
+
|
|
5354
|
+
// In-memory editor state: { bucketName: { clarify: [tier...], critique: [tier...] } }
|
|
5355
|
+
// covering all six buckets. `null` until the editor is populated. Both the hidden
|
|
5356
|
+
// carrier input and the sparse-save descriptor serialize from this single source.
|
|
5357
|
+
let reviewPolicyState = null;
|
|
5358
|
+
|
|
5359
|
+
/**
|
|
5360
|
+
* Deep copy of one bucket's built-in default policy.
|
|
5361
|
+
* @param {string} name - bucket display name
|
|
5362
|
+
* @returns {{clarify:Array<string>, critique:Array<string>}}
|
|
5363
|
+
*/
|
|
5364
|
+
function getReviewPolicyBucketDefault(name) {
|
|
5365
|
+
const def = DEFAULT_REVIEW_POLICY_LADDER[name];
|
|
5366
|
+
return { clarify: def.clarify.slice(), critique: def.critique.slice() };
|
|
5367
|
+
}
|
|
5368
|
+
|
|
5369
|
+
/**
|
|
5370
|
+
* Fresh full-ladder state deep-copied from the built-in defaults.
|
|
5371
|
+
* @returns {Object<string, {clarify:Array<string>, critique:Array<string>}>}
|
|
5372
|
+
*/
|
|
5373
|
+
function cloneReviewPolicyLadder() {
|
|
5374
|
+
const ladder = {};
|
|
5375
|
+
REVIEW_POLICY_BUCKETS.forEach(bucket => {
|
|
5376
|
+
ladder[bucket.name] = getReviewPolicyBucketDefault(bucket.name);
|
|
5377
|
+
});
|
|
5378
|
+
return ladder;
|
|
5379
|
+
}
|
|
5380
|
+
|
|
5381
|
+
/**
|
|
5382
|
+
* Whether a bucket's current state equals its built-in default (order-sensitive).
|
|
5383
|
+
* @param {string} name - bucket display name
|
|
5384
|
+
* @returns {boolean}
|
|
5385
|
+
*/
|
|
5386
|
+
function reviewPolicyBucketMatchesDefault(name) {
|
|
5387
|
+
if (!reviewPolicyState || !reviewPolicyState[name]) return true;
|
|
5388
|
+
const current = reviewPolicyState[name];
|
|
5389
|
+
const def = DEFAULT_REVIEW_POLICY_LADDER[name];
|
|
5390
|
+
return (
|
|
5391
|
+
JSON.stringify(current.clarify) === JSON.stringify(def.clarify) &&
|
|
5392
|
+
JSON.stringify(current.critique) === JSON.stringify(def.critique)
|
|
5393
|
+
);
|
|
5394
|
+
}
|
|
5395
|
+
|
|
5396
|
+
/**
|
|
5397
|
+
* The sparse overrides object: only buckets that DIFFER from their default,
|
|
5398
|
+
* inserted in the fixed bucket order with clarify before critique so a
|
|
5399
|
+
* JSON.stringify of the result is deterministic. `{}` when everything is default.
|
|
5400
|
+
* @returns {Object<string, {clarify:Array<string>, critique:Array<string>}>}
|
|
5401
|
+
*/
|
|
5402
|
+
function getReviewPolicyOverridesObject() {
|
|
5403
|
+
const overrides = {};
|
|
5404
|
+
if (!reviewPolicyState) return overrides;
|
|
5405
|
+
REVIEW_POLICY_BUCKETS.forEach(bucket => {
|
|
5406
|
+
if (!reviewPolicyBucketMatchesDefault(bucket.name)) {
|
|
5407
|
+
const current = reviewPolicyState[bucket.name];
|
|
5408
|
+
overrides[bucket.name] = {
|
|
5409
|
+
clarify: current.clarify.slice(),
|
|
5410
|
+
critique: current.critique.slice(),
|
|
5411
|
+
};
|
|
5412
|
+
}
|
|
5413
|
+
});
|
|
5414
|
+
return overrides;
|
|
5415
|
+
}
|
|
5416
|
+
|
|
5417
|
+
/**
|
|
5418
|
+
* Canonical serialized overrides value for the sparse-save text descriptor and the
|
|
5419
|
+
* hidden carrier input. `'{}'` when every bucket matches its default.
|
|
5420
|
+
* @returns {string}
|
|
5421
|
+
*/
|
|
5422
|
+
function getReviewPolicyOverridesCanonicalString() {
|
|
5423
|
+
return JSON.stringify(getReviewPolicyOverridesObject());
|
|
5424
|
+
}
|
|
5425
|
+
|
|
5426
|
+
/**
|
|
5427
|
+
* Push the canonical overrides value onto the hidden carrier input (form-submission
|
|
5428
|
+
* contract). The sparse-save flow reads from the descriptor, so this only keeps the
|
|
5429
|
+
* DOM carrier in sync; it is a no-op when the editor is absent.
|
|
5430
|
+
*/
|
|
5431
|
+
function syncReviewPolicyHiddenInput() {
|
|
5432
|
+
const input = document.getElementById('review_policy_overrides_input');
|
|
5433
|
+
if (input) input.value = getReviewPolicyOverridesCanonicalString();
|
|
5434
|
+
}
|
|
5435
|
+
|
|
5436
|
+
/**
|
|
5437
|
+
* Local validation for one bucket's state. Returns an error message string, or ''
|
|
5438
|
+
* when the bucket is valid. Mirrors the backend contract: at most two rounds per
|
|
5439
|
+
* automation, every tier known, and at least one clarify round.
|
|
5440
|
+
* @param {string} name - bucket display name
|
|
5441
|
+
* @returns {string}
|
|
5442
|
+
*/
|
|
5443
|
+
function validateReviewPolicyBucket(name) {
|
|
5444
|
+
if (!reviewPolicyState || !reviewPolicyState[name]) return '';
|
|
5445
|
+
const current = reviewPolicyState[name];
|
|
5446
|
+
for (const automation of REVIEW_POLICY_AUTOMATIONS) {
|
|
5447
|
+
const rounds = current[automation];
|
|
5448
|
+
const label = REVIEW_POLICY_AUTOMATION_LABELS[automation];
|
|
5449
|
+
if (rounds.length > REVIEW_POLICY_MAX_ROUNDS) {
|
|
5450
|
+
return `${label} allows at most ${REVIEW_POLICY_MAX_ROUNDS} rounds.`;
|
|
5451
|
+
}
|
|
5452
|
+
for (const tier of rounds) {
|
|
5453
|
+
if (!REVIEW_POLICY_TIERS.includes(tier)) {
|
|
5454
|
+
return `Invalid ${label.toLowerCase()} tier selected.`;
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
}
|
|
5458
|
+
if (current.clarify.length < 1) {
|
|
5459
|
+
return 'Clarify must have at least one round.';
|
|
5460
|
+
}
|
|
5461
|
+
return '';
|
|
5462
|
+
}
|
|
5463
|
+
|
|
5464
|
+
/**
|
|
5465
|
+
* Set (or clear) a bucket's error container. Uses the shared `.visible` toggle so
|
|
5466
|
+
* no inline display is mutated.
|
|
5467
|
+
* @param {string} slug - bucket slug
|
|
5468
|
+
* @param {string} message - error text ('' clears it)
|
|
5469
|
+
*/
|
|
5470
|
+
function setReviewPolicyBucketError(slug, message) {
|
|
5471
|
+
const errorEl = document.querySelector(`[data-testid="review-policy-bucket-${slug}-error"]`);
|
|
5472
|
+
if (!errorEl) return;
|
|
5473
|
+
errorEl.textContent = message || '';
|
|
5474
|
+
errorEl.classList.toggle('visible', !!message);
|
|
5475
|
+
}
|
|
5476
|
+
|
|
5477
|
+
/**
|
|
5478
|
+
* Validate every bucket, painting per-bucket errors. Returns false if any bucket is
|
|
5479
|
+
* invalid. Safe to call when the editor is absent (returns true).
|
|
5480
|
+
* @returns {boolean}
|
|
5481
|
+
*/
|
|
5482
|
+
function validateReviewPolicyOverrides() {
|
|
5483
|
+
if (!reviewPolicyState) return true;
|
|
5484
|
+
let allValid = true;
|
|
5485
|
+
REVIEW_POLICY_BUCKETS.forEach(bucket => {
|
|
5486
|
+
const message = validateReviewPolicyBucket(bucket.name);
|
|
5487
|
+
setReviewPolicyBucketError(bucket.slug, message);
|
|
5488
|
+
const card = document.querySelector(`[data-testid="review-policy-bucket-${bucket.slug}"]`);
|
|
5489
|
+
if (card) card.classList.toggle('review-policy-bucket-card--invalid', !!message);
|
|
5490
|
+
if (message) allValid = false;
|
|
5491
|
+
});
|
|
5492
|
+
return allValid;
|
|
5493
|
+
}
|
|
5494
|
+
|
|
5495
|
+
/**
|
|
5496
|
+
* Refresh a bucket's default/override state label plus its error/invalid painting
|
|
5497
|
+
* from the current state. Called after every edit.
|
|
5498
|
+
* @param {{name:string, slug:string}} bucket
|
|
5499
|
+
*/
|
|
5500
|
+
function updateReviewPolicyBucketStatus(bucket) {
|
|
5501
|
+
const stateEl = document.querySelector(`[data-testid="review-policy-bucket-${bucket.slug}-state"]`);
|
|
5502
|
+
const isDefault = reviewPolicyBucketMatchesDefault(bucket.name);
|
|
5503
|
+
if (stateEl) {
|
|
5504
|
+
stateEl.textContent = isDefault ? 'Using built-in defaults' : 'Override active';
|
|
5505
|
+
stateEl.classList.toggle('review-policy-state--default', isDefault);
|
|
5506
|
+
stateEl.classList.toggle('review-policy-state--override', !isDefault);
|
|
5507
|
+
}
|
|
5508
|
+
const message = validateReviewPolicyBucket(bucket.name);
|
|
5509
|
+
setReviewPolicyBucketError(bucket.slug, message);
|
|
5510
|
+
const card = document.querySelector(`[data-testid="review-policy-bucket-${bucket.slug}"]`);
|
|
5511
|
+
if (card) card.classList.toggle('review-policy-bucket-card--invalid', !!message);
|
|
5512
|
+
}
|
|
5513
|
+
|
|
5514
|
+
/**
|
|
5515
|
+
* Common post-edit refresh: repaint the bucket status and re-sync the hidden input.
|
|
5516
|
+
* @param {{name:string, slug:string}} bucket
|
|
5517
|
+
*/
|
|
5518
|
+
function afterReviewPolicyChange(bucket) {
|
|
5519
|
+
updateReviewPolicyBucketStatus(bucket);
|
|
5520
|
+
syncReviewPolicyHiddenInput();
|
|
5521
|
+
}
|
|
5522
|
+
|
|
5523
|
+
/**
|
|
5524
|
+
* Build one per-round row: a tier <select> plus a remove-round button. The remove
|
|
5525
|
+
* button is disabled for a clarify group's last remaining round (clarify >= 1).
|
|
5526
|
+
* @param {{name:string, slug:string}} bucket
|
|
5527
|
+
* @param {string} automation - 'clarify' | 'critique'
|
|
5528
|
+
* @param {number} idx - zero-based round index
|
|
5529
|
+
* @param {string} tier - current tier value
|
|
5530
|
+
* @param {number} roundCount - total rounds currently in this automation
|
|
5531
|
+
* @returns {HTMLElement}
|
|
5532
|
+
*/
|
|
5533
|
+
function renderReviewPolicyRoundRow(bucket, automation, idx, tier, roundCount) {
|
|
5534
|
+
const row = document.createElement('div');
|
|
5535
|
+
row.className = 'review-policy-round';
|
|
5536
|
+
|
|
5537
|
+
const label = document.createElement('label');
|
|
5538
|
+
label.className = 'review-policy-round-label';
|
|
5539
|
+
|
|
5540
|
+
const labelText = document.createElement('span');
|
|
5541
|
+
labelText.className = 'review-policy-round-label-text';
|
|
5542
|
+
labelText.textContent = `Round ${idx + 1}`;
|
|
5543
|
+
label.appendChild(labelText);
|
|
5544
|
+
|
|
5545
|
+
const select = document.createElement('select');
|
|
5546
|
+
select.className = 'review-policy-tier-select';
|
|
5547
|
+
select.setAttribute('data-testid', `review-policy-${bucket.slug}-${automation}-round-${idx + 1}-tier`);
|
|
5548
|
+
REVIEW_POLICY_TIERS.forEach(tierValue => {
|
|
5549
|
+
const option = document.createElement('option');
|
|
5550
|
+
option.value = tierValue;
|
|
5551
|
+
option.textContent = REVIEW_POLICY_TIER_LABELS[tierValue];
|
|
5552
|
+
if (tierValue === tier) option.selected = true;
|
|
5553
|
+
select.appendChild(option);
|
|
5554
|
+
});
|
|
5555
|
+
select.addEventListener('change', () => {
|
|
5556
|
+
reviewPolicyState[bucket.name][automation][idx] = select.value;
|
|
5557
|
+
afterReviewPolicyChange(bucket);
|
|
5558
|
+
});
|
|
5559
|
+
label.appendChild(select);
|
|
5560
|
+
row.appendChild(label);
|
|
5561
|
+
|
|
5562
|
+
const removeBtn = document.createElement('button');
|
|
5563
|
+
removeBtn.type = 'button';
|
|
5564
|
+
removeBtn.className = 'review-policy-remove-round';
|
|
5565
|
+
removeBtn.setAttribute('data-testid', `review-policy-${bucket.slug}-${automation}-remove-round-${idx + 1}`);
|
|
5566
|
+
removeBtn.textContent = 'Remove round';
|
|
5567
|
+
if (automation === 'clarify' && roundCount <= 1) {
|
|
5568
|
+
removeBtn.disabled = true;
|
|
5569
|
+
}
|
|
5570
|
+
removeBtn.addEventListener('click', () => handleReviewPolicyRemoveRound(bucket, automation, idx));
|
|
5571
|
+
row.appendChild(removeBtn);
|
|
5572
|
+
|
|
5573
|
+
return row;
|
|
5574
|
+
}
|
|
5575
|
+
|
|
5576
|
+
/**
|
|
5577
|
+
* Build one automation group (clarify or critique) with its round rows and an
|
|
5578
|
+
* add-round button (disabled at the two-round cap).
|
|
5579
|
+
* @param {{name:string, slug:string}} bucket
|
|
5580
|
+
* @param {string} automation - 'clarify' | 'critique'
|
|
5581
|
+
* @returns {HTMLElement}
|
|
5582
|
+
*/
|
|
5583
|
+
function renderReviewPolicyGroup(bucket, automation) {
|
|
5584
|
+
const rounds = reviewPolicyState[bucket.name][automation];
|
|
5585
|
+
const group = document.createElement('div');
|
|
5586
|
+
group.className = 'review-policy-group';
|
|
5587
|
+
group.setAttribute('data-testid', `review-policy-${bucket.slug}-${automation}-group`);
|
|
5588
|
+
|
|
5589
|
+
const title = document.createElement('div');
|
|
5590
|
+
title.className = 'review-policy-group-title';
|
|
5591
|
+
title.textContent = REVIEW_POLICY_AUTOMATION_LABELS[automation];
|
|
5592
|
+
group.appendChild(title);
|
|
5593
|
+
|
|
5594
|
+
const roundsWrap = document.createElement('div');
|
|
5595
|
+
roundsWrap.className = 'review-policy-rounds';
|
|
5596
|
+
if (rounds.length === 0) {
|
|
5597
|
+
const empty = document.createElement('p');
|
|
5598
|
+
empty.className = 'review-policy-empty';
|
|
5599
|
+
empty.textContent = 'Skipped (no rounds)';
|
|
5600
|
+
roundsWrap.appendChild(empty);
|
|
5601
|
+
} else {
|
|
5602
|
+
rounds.forEach((tier, idx) => {
|
|
5603
|
+
roundsWrap.appendChild(renderReviewPolicyRoundRow(bucket, automation, idx, tier, rounds.length));
|
|
5604
|
+
});
|
|
5605
|
+
}
|
|
5606
|
+
group.appendChild(roundsWrap);
|
|
5607
|
+
|
|
5608
|
+
const addBtn = document.createElement('button');
|
|
5609
|
+
addBtn.type = 'button';
|
|
5610
|
+
addBtn.className = 'review-policy-add-round';
|
|
5611
|
+
addBtn.setAttribute('data-testid', `review-policy-${bucket.slug}-${automation}-add-round`);
|
|
5612
|
+
addBtn.textContent = 'Add round';
|
|
5613
|
+
if (rounds.length >= REVIEW_POLICY_MAX_ROUNDS) {
|
|
5614
|
+
addBtn.disabled = true;
|
|
5615
|
+
}
|
|
5616
|
+
addBtn.addEventListener('click', () => handleReviewPolicyAddRound(bucket, automation));
|
|
5617
|
+
group.appendChild(addBtn);
|
|
5618
|
+
|
|
5619
|
+
return group;
|
|
5620
|
+
}
|
|
5621
|
+
|
|
5622
|
+
/**
|
|
5623
|
+
* Build one bucket card: header (name, fixed range, state label, reset button),
|
|
5624
|
+
* error container, and the clarify/critique groups. All text is set via
|
|
5625
|
+
* textContent, so no unescaped values reach the DOM.
|
|
5626
|
+
* @param {{name:string, slug:string, range:string}} bucket
|
|
5627
|
+
* @returns {HTMLElement}
|
|
5628
|
+
*/
|
|
5629
|
+
function renderReviewPolicyBucketCard(bucket) {
|
|
5630
|
+
const card = document.createElement('div');
|
|
5631
|
+
card.className = 'review-policy-bucket-card';
|
|
5632
|
+
card.setAttribute('data-testid', `review-policy-bucket-${bucket.slug}`);
|
|
5633
|
+
|
|
5634
|
+
const header = document.createElement('div');
|
|
5635
|
+
header.className = 'review-policy-bucket-header';
|
|
5636
|
+
|
|
5637
|
+
const heading = document.createElement('div');
|
|
5638
|
+
heading.className = 'review-policy-bucket-heading';
|
|
5639
|
+
const nameEl = document.createElement('span');
|
|
5640
|
+
nameEl.className = 'review-policy-bucket-name';
|
|
5641
|
+
nameEl.textContent = bucket.name;
|
|
5642
|
+
const rangeEl = document.createElement('span');
|
|
5643
|
+
rangeEl.className = 'review-policy-bucket-range';
|
|
5644
|
+
rangeEl.textContent = `Difficulty ${bucket.range}`;
|
|
5645
|
+
heading.appendChild(nameEl);
|
|
5646
|
+
heading.appendChild(rangeEl);
|
|
5647
|
+
header.appendChild(heading);
|
|
5648
|
+
|
|
5649
|
+
const stateEl = document.createElement('span');
|
|
5650
|
+
stateEl.className = 'review-policy-state';
|
|
5651
|
+
stateEl.setAttribute('data-testid', `review-policy-bucket-${bucket.slug}-state`);
|
|
5652
|
+
header.appendChild(stateEl);
|
|
5653
|
+
|
|
5654
|
+
const resetBtn = document.createElement('button');
|
|
5655
|
+
resetBtn.type = 'button';
|
|
5656
|
+
resetBtn.className = 'review-policy-reset-bucket';
|
|
5657
|
+
resetBtn.setAttribute('data-testid', `review-policy-${bucket.slug}-reset`);
|
|
5658
|
+
resetBtn.textContent = 'Reset bucket';
|
|
5659
|
+
resetBtn.addEventListener('click', () => handleReviewPolicyResetBucket(bucket));
|
|
5660
|
+
header.appendChild(resetBtn);
|
|
5661
|
+
|
|
5662
|
+
card.appendChild(header);
|
|
5663
|
+
|
|
5664
|
+
const errorEl = document.createElement('div');
|
|
5665
|
+
errorEl.className = 'form-error review-policy-bucket-error';
|
|
5666
|
+
errorEl.setAttribute('data-testid', `review-policy-bucket-${bucket.slug}-error`);
|
|
5667
|
+
card.appendChild(errorEl);
|
|
5668
|
+
|
|
5669
|
+
const groups = document.createElement('div');
|
|
5670
|
+
groups.className = 'review-policy-groups';
|
|
5671
|
+
REVIEW_POLICY_AUTOMATIONS.forEach(automation => {
|
|
5672
|
+
groups.appendChild(renderReviewPolicyGroup(bucket, automation));
|
|
5673
|
+
});
|
|
5674
|
+
card.appendChild(groups);
|
|
5675
|
+
|
|
5676
|
+
return card;
|
|
5677
|
+
}
|
|
5678
|
+
|
|
5679
|
+
/**
|
|
5680
|
+
* Replace one bucket card in place (used after a structural add/remove/reset).
|
|
5681
|
+
* @param {{name:string, slug:string, range:string}} bucket
|
|
5682
|
+
*/
|
|
5683
|
+
function refreshReviewPolicyBucketCard(bucket) {
|
|
5684
|
+
const existing = document.querySelector(`[data-testid="review-policy-bucket-${bucket.slug}"]`);
|
|
5685
|
+
if (!existing || !existing.parentNode) return;
|
|
5686
|
+
existing.parentNode.replaceChild(renderReviewPolicyBucketCard(bucket), existing);
|
|
5687
|
+
updateReviewPolicyBucketStatus(bucket);
|
|
5688
|
+
}
|
|
5689
|
+
|
|
5690
|
+
/**
|
|
5691
|
+
* Build the compact 1-10 boundary reference strip (display-only, not editable).
|
|
5692
|
+
*/
|
|
5693
|
+
function renderReviewPolicyBoundaryStrip() {
|
|
5694
|
+
const strip = document.getElementById('review_policy_boundary_strip');
|
|
5695
|
+
if (!strip) return;
|
|
5696
|
+
strip.textContent = '';
|
|
5697
|
+
REVIEW_POLICY_BUCKETS.forEach(bucket => {
|
|
5698
|
+
const item = document.createElement('span');
|
|
5699
|
+
item.className = 'review-policy-boundary-item';
|
|
5700
|
+
const label = document.createElement('span');
|
|
5701
|
+
label.className = 'review-policy-boundary-label';
|
|
5702
|
+
label.textContent = bucket.name;
|
|
5703
|
+
const range = document.createElement('span');
|
|
5704
|
+
range.className = 'review-policy-boundary-range';
|
|
5705
|
+
range.textContent = bucket.range;
|
|
5706
|
+
item.appendChild(label);
|
|
5707
|
+
item.appendChild(range);
|
|
5708
|
+
strip.appendChild(item);
|
|
5709
|
+
});
|
|
5710
|
+
}
|
|
5711
|
+
|
|
5712
|
+
/**
|
|
5713
|
+
* Render the full editor (boundary strip + all six bucket cards) from state, then
|
|
5714
|
+
* paint each bucket's status. No-op when the editor root is absent.
|
|
5715
|
+
*/
|
|
5716
|
+
function renderReviewPolicyEditor() {
|
|
5717
|
+
const container = document.getElementById('review_policy_buckets');
|
|
5718
|
+
if (!container || !reviewPolicyState) return;
|
|
5719
|
+
renderReviewPolicyBoundaryStrip();
|
|
5720
|
+
container.textContent = '';
|
|
5721
|
+
REVIEW_POLICY_BUCKETS.forEach(bucket => {
|
|
5722
|
+
container.appendChild(renderReviewPolicyBucketCard(bucket));
|
|
5723
|
+
});
|
|
5724
|
+
REVIEW_POLICY_BUCKETS.forEach(bucket => updateReviewPolicyBucketStatus(bucket));
|
|
4164
5725
|
}
|
|
4165
5726
|
|
|
4166
5727
|
/**
|
|
4167
|
-
*
|
|
4168
|
-
*
|
|
5728
|
+
* Add a round (at the built-in new-round tier) to a bucket's automation, capped at
|
|
5729
|
+
* two rounds, then refresh the card.
|
|
5730
|
+
* @param {{name:string, slug:string, range:string}} bucket
|
|
5731
|
+
* @param {string} automation - 'clarify' | 'critique'
|
|
4169
5732
|
*/
|
|
4170
|
-
function
|
|
4171
|
-
const
|
|
4172
|
-
if (
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
counter.classList.toggle('over-limit', byteLength > CI_FOLLOWUP_INSTRUCTIONS_MAX_BYTES);
|
|
5733
|
+
function handleReviewPolicyAddRound(bucket, automation) {
|
|
5734
|
+
const rounds = reviewPolicyState[bucket.name][automation];
|
|
5735
|
+
if (rounds.length >= REVIEW_POLICY_MAX_ROUNDS) return;
|
|
5736
|
+
rounds.push(REVIEW_POLICY_DEFAULT_NEW_TIER);
|
|
5737
|
+
refreshReviewPolicyBucketCard(bucket);
|
|
5738
|
+
afterReviewPolicyChange(bucket);
|
|
4177
5739
|
}
|
|
4178
5740
|
|
|
4179
5741
|
/**
|
|
4180
|
-
*
|
|
4181
|
-
*
|
|
4182
|
-
*
|
|
4183
|
-
*
|
|
4184
|
-
* survive page load.
|
|
4185
|
-
* @param {{clearInstructionsOnPollOnly?: boolean}} [options]
|
|
5742
|
+
* Remove a round from a bucket's automation. Clarify can never drop below one round.
|
|
5743
|
+
* @param {{name:string, slug:string, range:string}} bucket
|
|
5744
|
+
* @param {string} automation - 'clarify' | 'critique'
|
|
5745
|
+
* @param {number} idx - zero-based round index to remove
|
|
4186
5746
|
*/
|
|
4187
|
-
function
|
|
4188
|
-
const
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
if (clearInstructionsOnPollOnly) instructionsEl.value = '';
|
|
4194
|
-
instructionsEl.disabled = true;
|
|
4195
|
-
instructionsEl.required = false;
|
|
4196
|
-
} else if (strategy === 'custom') {
|
|
4197
|
-
instructionsEl.disabled = false;
|
|
4198
|
-
instructionsEl.required = true;
|
|
4199
|
-
} else { // fix_and_iterate
|
|
4200
|
-
instructionsEl.disabled = false;
|
|
4201
|
-
instructionsEl.required = false;
|
|
4202
|
-
}
|
|
4203
|
-
}
|
|
4204
|
-
updateCiFollowupInstructionsByteCounter();
|
|
5747
|
+
function handleReviewPolicyRemoveRound(bucket, automation, idx) {
|
|
5748
|
+
const rounds = reviewPolicyState[bucket.name][automation];
|
|
5749
|
+
if (automation === 'clarify' && rounds.length <= 1) return;
|
|
5750
|
+
rounds.splice(idx, 1);
|
|
5751
|
+
refreshReviewPolicyBucketCard(bucket);
|
|
5752
|
+
afterReviewPolicyChange(bucket);
|
|
4205
5753
|
}
|
|
4206
5754
|
|
|
4207
5755
|
/**
|
|
4208
|
-
*
|
|
4209
|
-
*
|
|
4210
|
-
* re-enable, then apply strategy-specific instruction state (without clearing).
|
|
5756
|
+
* Reset one bucket to its built-in default and refresh the card.
|
|
5757
|
+
* @param {{name:string, slug:string, range:string}} bucket
|
|
4211
5758
|
*/
|
|
4212
|
-
function
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
const innerInputs = controls.querySelectorAll('input, textarea, select');
|
|
4218
|
-
if (isOn) {
|
|
4219
|
-
controls.classList.remove('hidden');
|
|
4220
|
-
innerInputs.forEach(el => { el.disabled = false; });
|
|
4221
|
-
applyCiFollowupStrategyState({ clearInstructionsOnPollOnly: false });
|
|
4222
|
-
} else {
|
|
4223
|
-
controls.classList.add('hidden');
|
|
4224
|
-
innerInputs.forEach(el => { el.disabled = true; });
|
|
4225
|
-
}
|
|
5759
|
+
function handleReviewPolicyResetBucket(bucket) {
|
|
5760
|
+
if (!reviewPolicyState) return;
|
|
5761
|
+
reviewPolicyState[bucket.name] = getReviewPolicyBucketDefault(bucket.name);
|
|
5762
|
+
refreshReviewPolicyBucketCard(bucket);
|
|
5763
|
+
afterReviewPolicyChange(bucket);
|
|
4226
5764
|
}
|
|
4227
5765
|
|
|
4228
5766
|
/**
|
|
4229
|
-
*
|
|
4230
|
-
* value (canonical JSON string), an already-parsed object, or null/empty/invalid.
|
|
4231
|
-
* Invalid or empty values disable the feature and reset inner controls to defaults;
|
|
4232
|
-
* a valid config enables it and populates every control, preserving stored
|
|
4233
|
-
* instructions even for poll_only so MCP-authored values are not silently altered.
|
|
4234
|
-
* @param {string|Object|null} rawValue
|
|
5767
|
+
* Reset every bucket to its built-in default and re-render the whole editor.
|
|
4235
5768
|
*/
|
|
4236
|
-
function
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
5769
|
+
function handleReviewPolicyResetAll() {
|
|
5770
|
+
if (!reviewPolicyState) return;
|
|
5771
|
+
reviewPolicyState = cloneReviewPolicyLadder();
|
|
5772
|
+
renderReviewPolicyEditor();
|
|
5773
|
+
syncReviewPolicyHiddenInput();
|
|
5774
|
+
}
|
|
5775
|
+
|
|
5776
|
+
/**
|
|
5777
|
+
* Initialize the review-policy editor from the stored GET value. Accepts a parsed
|
|
5778
|
+
* object, a JSON string, or null/invalid (all treated as no overrides). Only known
|
|
5779
|
+
* buckets with a well-formed clarify list are merged over the defaults; anything
|
|
5780
|
+
* malformed falls back to that bucket's default (defensive — the backend validates
|
|
5781
|
+
* on write). No-op when the editor root is absent.
|
|
5782
|
+
* @param {Object|string|null} rawValue
|
|
5783
|
+
*/
|
|
5784
|
+
function populateReviewPolicyOverrides(rawValue) {
|
|
5785
|
+
const editor = document.getElementById('review_policy_overrides_editor');
|
|
5786
|
+
if (!editor) return;
|
|
5787
|
+
|
|
5788
|
+
let overrides = {};
|
|
5789
|
+
if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) {
|
|
5790
|
+
overrides = rawValue;
|
|
4241
5791
|
} else if (typeof rawValue === 'string' && rawValue.trim() !== '') {
|
|
4242
5792
|
try {
|
|
4243
|
-
|
|
5793
|
+
const parsed = JSON.parse(rawValue);
|
|
5794
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
5795
|
+
overrides = parsed;
|
|
5796
|
+
}
|
|
4244
5797
|
} catch (e) {
|
|
4245
|
-
|
|
5798
|
+
overrides = {};
|
|
4246
5799
|
}
|
|
4247
5800
|
}
|
|
4248
5801
|
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
5802
|
+
reviewPolicyState = cloneReviewPolicyLadder();
|
|
5803
|
+
REVIEW_POLICY_BUCKETS.forEach(bucket => {
|
|
5804
|
+
const stored = overrides[bucket.name];
|
|
5805
|
+
if (!stored || typeof stored !== 'object' || !Array.isArray(stored.clarify)) return;
|
|
5806
|
+
const clarify = stored.clarify
|
|
5807
|
+
.filter(tier => REVIEW_POLICY_TIERS.includes(tier))
|
|
5808
|
+
.slice(0, REVIEW_POLICY_MAX_ROUNDS);
|
|
5809
|
+
const critique = Array.isArray(stored.critique)
|
|
5810
|
+
? stored.critique.filter(tier => REVIEW_POLICY_TIERS.includes(tier)).slice(0, REVIEW_POLICY_MAX_ROUNDS)
|
|
5811
|
+
: [];
|
|
5812
|
+
if (clarify.length >= 1) {
|
|
5813
|
+
reviewPolicyState[bucket.name] = { clarify, critique };
|
|
5814
|
+
}
|
|
4262
5815
|
});
|
|
4263
|
-
|
|
4264
|
-
|
|
5816
|
+
|
|
5817
|
+
renderReviewPolicyEditor();
|
|
5818
|
+
syncReviewPolicyHiddenInput();
|
|
4265
5819
|
}
|
|
4266
5820
|
|
|
4267
5821
|
/**
|
|
@@ -4346,6 +5900,12 @@ function setupConditionalVisibilityListeners() {
|
|
|
4346
5900
|
if (ciInstructions) {
|
|
4347
5901
|
ciInstructions.addEventListener('input', updateCiFollowupInstructionsByteCounter);
|
|
4348
5902
|
}
|
|
5903
|
+
// BAPI-579: the review-policy "reset all" button. Per-bucket controls (round
|
|
5904
|
+
// selects, add/remove, per-bucket reset) are wired at render time.
|
|
5905
|
+
const reviewPolicyResetAll = document.getElementById('review_policy_reset_all');
|
|
5906
|
+
if (reviewPolicyResetAll) {
|
|
5907
|
+
reviewPolicyResetAll.addEventListener('click', handleReviewPolicyResetAll);
|
|
5908
|
+
}
|
|
4349
5909
|
|
|
4350
5910
|
// Apply initial state for all three dependent regions before baseline capture.
|
|
4351
5911
|
toggleDeepResearchTimeoutVisibility();
|
|
@@ -4388,10 +5948,15 @@ const PROJECT_DETAIL_FIELD_DESCRIPTORS = [
|
|
|
4388
5948
|
{ field: 'review_instructions', type: 'text', id: 'review_instructions' },
|
|
4389
5949
|
{ field: 'documentation_instructions', type: 'text', id: 'documentation_instructions' },
|
|
4390
5950
|
{ field: 'allow_mutating_smoke_ops', type: 'boolean', id: 'allow_mutating_smoke_ops' },
|
|
5951
|
+
{ field: 'difficulty_adaptive_review_enabled', type: 'boolean', id: 'difficulty_adaptive_review_enabled' },
|
|
4391
5952
|
{ field: 'selected_mcp_slugs', type: 'array', getValue: getSelectedMcpSlugs, orderInsensitive: true },
|
|
4392
5953
|
// CI Follow-up: a structured sub-section that diffs as a stable canonical JSON
|
|
4393
5954
|
// string ('' when disabled). getPayloadValueForField parses it back to an object.
|
|
4394
5955
|
{ field: 'ci_followup_config', type: 'text', getValue: getCiFollowupConfigCanonicalString },
|
|
5956
|
+
// BAPI-579: difficulty-adaptive review policy overrides. Diffs as a stable
|
|
5957
|
+
// canonical JSON string ('{}' when every bucket matches the built-in default).
|
|
5958
|
+
// getPayloadValueForField parses it back to the object the backend expects.
|
|
5959
|
+
{ field: 'difficulty_adaptive_review_policy_overrides', type: 'text', getValue: getReviewPolicyOverridesCanonicalString },
|
|
4395
5960
|
|
|
4396
5961
|
// Code Reviewer (the four *_correctness_standards render in the Code Writer
|
|
4397
5962
|
// drawer but persist via the code_reviewer payload — see FIELD_TO_SECTION)
|
|
@@ -4448,7 +6013,9 @@ const FIELD_TO_SECTION = {
|
|
|
4448
6013
|
review_instructions: 'code_writer',
|
|
4449
6014
|
documentation_instructions: 'code_writer',
|
|
4450
6015
|
ci_followup_config: 'code_writer',
|
|
6016
|
+
difficulty_adaptive_review_policy_overrides: 'code_writer',
|
|
4451
6017
|
allow_mutating_smoke_ops: 'code_writer',
|
|
6018
|
+
difficulty_adaptive_review_enabled: 'code_writer',
|
|
4452
6019
|
selected_mcp_slugs: 'code_writer',
|
|
4453
6020
|
// code_reviewer
|
|
4454
6021
|
frontend_correctness_standards: 'code_reviewer',
|
|
@@ -4526,6 +6093,16 @@ function getPayloadValueForField(fieldName, changedValue, estimateScaleValue = n
|
|
|
4526
6093
|
// when off; otherwise an object the backend's shared validator canonicalizes.
|
|
4527
6094
|
return changedValue === '' ? null : JSON.parse(changedValue);
|
|
4528
6095
|
}
|
|
6096
|
+
if (fieldName === 'difficulty_adaptive_review_policy_overrides') {
|
|
6097
|
+
// changedValue is the canonical JSON string ('{}' when all buckets are
|
|
6098
|
+
// default). Send it as a parsed object; the backend validates and persists
|
|
6099
|
+
// it (an empty {} clears any prior override back to the built-in ladder).
|
|
6100
|
+
try {
|
|
6101
|
+
return JSON.parse(changedValue);
|
|
6102
|
+
} catch (e) {
|
|
6103
|
+
return {};
|
|
6104
|
+
}
|
|
6105
|
+
}
|
|
4529
6106
|
return changedValue;
|
|
4530
6107
|
}
|
|
4531
6108
|
|
|
@@ -4580,6 +6157,7 @@ const EXPLANATION_TEXTS = {
|
|
|
4580
6157
|
'alternate_version_directories': 'Which directories hold the alternate SFCC version (the one that isn\'t your base version)? Input partial directory paths that can identify them. Enter one directory path per line.',
|
|
4581
6158
|
'write_tests': 'If enabled, the Code Writer will attempt to generate unit tests alongside the code draft. Optional, defaults to true.',
|
|
4582
6159
|
'allow_mutating_smoke_ops': 'If selected, the AI will work to genuinely test functionality through real operations, executed against the local environment. This will provide greater quality assurance, but requires greater trust. Only runs on the sandbox.',
|
|
6160
|
+
'difficulty_adaptive_review_enabled': 'OFF by default. When enabled, easy/medium tickets may run one lower-cost review round instead of two. Hard, unknown, or error/failure cases always keep the full premium two-round review, so review quality never degrades silently.',
|
|
4583
6161
|
'review_instructions': 'Instructions that guide how the AI validates that a plan was implemented correctly. Use this to define the specific verification steps the AI should follow during its review — such as which tools to invoke, what checks to run, and how to confirm correctness.',
|
|
4584
6162
|
'selected_mcp_slugs': 'MCP Validation Manuals: choose which canonical MCP manuals are supplied to the final plan reviewer when it generates review, smoke, integration, and documentation steps. The available choices are loaded from the backend MCP docs catalog.',
|
|
4585
6163
|
'pwa_overrides_directories': 'Specify the directories that contain your PWA Kit template overrides. Enter one directory path per line (e.g., `overrides`, `app/templates`, `custom-overrides`). These directories will be given special consideration when the AI generates or modifies code.',
|
|
@@ -4871,9 +6449,15 @@ function populateProjectForms(data) {
|
|
|
4871
6449
|
if (codeWriterForm.elements.allow_mutating_smoke_ops) {
|
|
4872
6450
|
codeWriterForm.elements.allow_mutating_smoke_ops.checked = !!writer.allow_mutating_smoke_ops;
|
|
4873
6451
|
}
|
|
6452
|
+
if (codeWriterForm.elements.difficulty_adaptive_review_enabled) {
|
|
6453
|
+
codeWriterForm.elements.difficulty_adaptive_review_enabled.checked = !!writer.difficulty_adaptive_review_enabled;
|
|
6454
|
+
}
|
|
4874
6455
|
populateMcpDocSelector(data.available_mcp_docs || [], writer.selected_mcp_slugs || []);
|
|
4875
6456
|
// CI Follow-up: parse the raw DB text into the structured controls (OFF when null).
|
|
4876
6457
|
populateCiFollowupConfig(writer.ci_followup_config);
|
|
6458
|
+
// BAPI-579: render the difficulty-adaptive review policy editor from the
|
|
6459
|
+
// stored overrides object ({} = built-in defaults).
|
|
6460
|
+
populateReviewPolicyOverrides(writer.difficulty_adaptive_review_policy_overrides);
|
|
4877
6461
|
}
|
|
4878
6462
|
|
|
4879
6463
|
// Correctness standards (moved to Code Writer drawer, still saved via code_reviewer payload)
|
|
@@ -5132,6 +6716,13 @@ function validateAllForms() {
|
|
|
5132
6716
|
}
|
|
5133
6717
|
});
|
|
5134
6718
|
|
|
6719
|
+
// BAPI-579: the review-policy editor's per-bucket rules (>=1 clarify round,
|
|
6720
|
+
// <=2 rounds, known tiers) are validated separately from the native inputs,
|
|
6721
|
+
// painting errors on the affected bucket's error container.
|
|
6722
|
+
if (!validateReviewPolicyOverrides()) {
|
|
6723
|
+
allValid = false;
|
|
6724
|
+
}
|
|
6725
|
+
|
|
5135
6726
|
return allValid;
|
|
5136
6727
|
}
|
|
5137
6728
|
|
|
@@ -5215,76 +6806,449 @@ async function init() {
|
|
|
5215
6806
|
const urlParams = new URLSearchParams(window.location.search);
|
|
5216
6807
|
const projectId = urlParams.get('projectId');
|
|
5217
6808
|
|
|
5218
|
-
if (!projectId) {
|
|
5219
|
-
(0,_modal_js__WEBPACK_IMPORTED_MODULE_0__.showModal)('Error', 'Project ID not found in URL.');
|
|
5220
|
-
return;
|
|
5221
|
-
}
|
|
6809
|
+
if (!projectId) {
|
|
6810
|
+
(0,_modal_js__WEBPACK_IMPORTED_MODULE_0__.showModal)('Error', 'Project ID not found in URL.');
|
|
6811
|
+
return;
|
|
6812
|
+
}
|
|
6813
|
+
|
|
6814
|
+
await loadProjectDetail(projectId);
|
|
6815
|
+
|
|
6816
|
+
if (saveChangesBtn) {
|
|
6817
|
+
saveChangesBtn.addEventListener('click', handleSaveProject);
|
|
6818
|
+
}
|
|
6819
|
+
|
|
6820
|
+
if (saveChangesBtn) {
|
|
6821
|
+
saveChangesBtn.textContent = 'Save Configuration';
|
|
6822
|
+
saveChangesBtn.style.position = 'fixed';
|
|
6823
|
+
saveChangesBtn.style.bottom = '20px';
|
|
6824
|
+
saveChangesBtn.style.left = '50%';
|
|
6825
|
+
saveChangesBtn.style.transform = 'translateX(-50%)';
|
|
6826
|
+
saveChangesBtn.style.zIndex = '1000';
|
|
6827
|
+
saveChangesBtn.style.padding = '10px 20px';
|
|
6828
|
+
saveChangesBtn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
|
|
6829
|
+
}
|
|
6830
|
+
|
|
6831
|
+
initializeExplainLinkFunctionality();
|
|
6832
|
+
|
|
6833
|
+
const allSaveButtons = document.querySelectorAll('button[type="submit"]');
|
|
6834
|
+
allSaveButtons.forEach(button => {
|
|
6835
|
+
if (button !== saveChangesBtn) {
|
|
6836
|
+
button.style.display = 'none';
|
|
6837
|
+
}
|
|
6838
|
+
});
|
|
6839
|
+
|
|
6840
|
+
if (estimateScaleJsonTextarea) {
|
|
6841
|
+
estimateScaleJsonTextarea.addEventListener('input', () => {
|
|
6842
|
+
try {
|
|
6843
|
+
if (estimateScaleJsonTextarea.value.trim()) {
|
|
6844
|
+
JSON.parse(estimateScaleJsonTextarea.value);
|
|
6845
|
+
}
|
|
6846
|
+
estimateScaleJsonErrorDiv.classList.remove('visible');
|
|
6847
|
+
estimateScaleJsonTextarea.style.borderColor = '';
|
|
6848
|
+
} catch (e) {
|
|
6849
|
+
estimateScaleJsonErrorDiv.textContent = 'Invalid JSON format.';
|
|
6850
|
+
estimateScaleJsonErrorDiv.classList.add('visible');
|
|
6851
|
+
estimateScaleJsonTextarea.style.borderColor = 'var(--error-color)';
|
|
6852
|
+
}
|
|
6853
|
+
});
|
|
6854
|
+
}
|
|
6855
|
+
|
|
6856
|
+
setupValidationListeners();
|
|
6857
|
+
|
|
6858
|
+
// Wire conditional visibility (deep-research timeout, story points field, CI
|
|
6859
|
+
// follow-up controls) and apply initial hidden/disabled state BEFORE the
|
|
6860
|
+
// baseline snapshot so dependent fields are not flagged permanently dirty.
|
|
6861
|
+
setupConditionalVisibilityListeners();
|
|
6862
|
+
|
|
6863
|
+
// Capture the baseline only after the forms are populated, the dynamic
|
|
6864
|
+
// MCP checkboxes are rendered (in loadProjectDetail -> populateProjectForms),
|
|
6865
|
+
// the explain links are wired, the estimate-scale live-validation listener
|
|
6866
|
+
// is attached, the validation listeners are set up, and conditional
|
|
6867
|
+
// visibility has been applied — otherwise async-rendered or dependent fields
|
|
6868
|
+
// would appear permanently dirty.
|
|
6869
|
+
captureProjectDetailBaseline();
|
|
6870
|
+
}
|
|
6871
|
+
}
|
|
6872
|
+
|
|
6873
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
6874
|
+
|
|
6875
|
+
// Named exports for unit testing (DOM-free where possible). Exporting does not
|
|
6876
|
+
// change browser behavior — the page still runs via the DOMContentLoaded hook.
|
|
6877
|
+
|
|
6878
|
+
|
|
6879
|
+
|
|
6880
|
+
/***/ },
|
|
6881
|
+
|
|
6882
|
+
/***/ "./src/js/run-dashboard.js"
|
|
6883
|
+
/*!*********************************!*\
|
|
6884
|
+
!*** ./src/js/run-dashboard.js ***!
|
|
6885
|
+
\*********************************/
|
|
6886
|
+
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
|
6887
|
+
|
|
6888
|
+
__webpack_require__.r(__webpack_exports__);
|
|
6889
|
+
/* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils.js */ "./src/js/utils.js");
|
|
6890
|
+
/* harmony import */ var _modal_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./modal.js */ "./src/js/modal.js");
|
|
6891
|
+
// src/js/run-dashboard.js
|
|
6892
|
+
/**
|
|
6893
|
+
* Run Dashboard page module (BAPI-539, Epic Conductor v2, T8).
|
|
6894
|
+
*
|
|
6895
|
+
* Pure projection over truth: polls/refreshes the read-only dashboard data
|
|
6896
|
+
* endpoint and re-renders the ticket x gate matrix, active jobs, and
|
|
6897
|
+
* needs_human cards. The ONLY mutation this module triggers is the unpark
|
|
6898
|
+
* POST, owned by T5 (api/routes/epic_runs.py::unpark_epic_ticket_endpoint) —
|
|
6899
|
+
* the frontend only wires the button to it and re-renders on success.
|
|
6900
|
+
*/
|
|
6901
|
+
|
|
6902
|
+
|
|
6903
|
+
|
|
6904
|
+
const REFRESH_INTERVAL_MS = 30000;
|
|
6905
|
+
|
|
6906
|
+
let pollTimer = null;
|
|
6907
|
+
|
|
6908
|
+
function init() {
|
|
6909
|
+
const container = document.getElementById('run-dashboard-container');
|
|
6910
|
+
if (!container) {
|
|
6911
|
+
return;
|
|
6912
|
+
}
|
|
6913
|
+
|
|
6914
|
+
(0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.initDrawers)({ root: document, drawerSelector: '#run-dashboard-container .drawer' });
|
|
6915
|
+
|
|
6916
|
+
const refreshButton = container.querySelector('[data-testid="run-dashboard-refresh"]');
|
|
6917
|
+
if (refreshButton) {
|
|
6918
|
+
refreshButton.addEventListener('click', () => {
|
|
6919
|
+
refreshDashboard({ showScopedLoading: true });
|
|
6920
|
+
});
|
|
6921
|
+
}
|
|
6922
|
+
|
|
6923
|
+
container.addEventListener('click', (event) => {
|
|
6924
|
+
const button = event.target.closest('[data-testid="unpark-ticket-button"]');
|
|
6925
|
+
if (button) {
|
|
6926
|
+
handleUnparkClick(button);
|
|
6927
|
+
}
|
|
6928
|
+
});
|
|
6929
|
+
|
|
6930
|
+
const intervalMs = Number(container.dataset.refreshIntervalMs) || REFRESH_INTERVAL_MS;
|
|
6931
|
+
if (pollTimer) {
|
|
6932
|
+
clearInterval(pollTimer);
|
|
6933
|
+
}
|
|
6934
|
+
pollTimer = setInterval(() => {
|
|
6935
|
+
refreshDashboard({ showScopedLoading: false });
|
|
6936
|
+
}, intervalMs);
|
|
6937
|
+
}
|
|
6938
|
+
|
|
6939
|
+
function setScopedError(selector, message) {
|
|
6940
|
+
const container = document.getElementById('run-dashboard-container');
|
|
6941
|
+
if (!container) return;
|
|
6942
|
+
const panel = container.querySelector(selector);
|
|
6943
|
+
if (!panel) return;
|
|
6944
|
+
if (message) {
|
|
6945
|
+
panel.textContent = message;
|
|
6946
|
+
panel.classList.remove('hidden');
|
|
6947
|
+
panel.classList.add('visible');
|
|
6948
|
+
} else {
|
|
6949
|
+
panel.textContent = '';
|
|
6950
|
+
panel.classList.add('hidden');
|
|
6951
|
+
panel.classList.remove('visible');
|
|
6952
|
+
}
|
|
6953
|
+
}
|
|
6954
|
+
|
|
6955
|
+
async function refreshDashboard({ showScopedLoading = false } = {}) {
|
|
6956
|
+
const container = document.getElementById('run-dashboard-container');
|
|
6957
|
+
if (!container) return;
|
|
6958
|
+
const refreshUrl = container.dataset.refreshUrl;
|
|
6959
|
+
const refreshButton = container.querySelector('[data-testid="run-dashboard-refresh"]');
|
|
6960
|
+
|
|
6961
|
+
if (showScopedLoading) {
|
|
6962
|
+
(0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.showLoading)();
|
|
6963
|
+
if (refreshButton) refreshButton.disabled = true;
|
|
6964
|
+
}
|
|
6965
|
+
|
|
6966
|
+
try {
|
|
6967
|
+
const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.apiCall)(refreshUrl, 'GET');
|
|
6968
|
+
if (data === null) {
|
|
6969
|
+
// Centralized 401 handling in apiCall already redirected.
|
|
6970
|
+
return;
|
|
6971
|
+
}
|
|
6972
|
+
setScopedError('[data-testid="run-dashboard-error"]', null);
|
|
6973
|
+
renderDashboard(data);
|
|
6974
|
+
} catch (error) {
|
|
6975
|
+
setScopedError(
|
|
6976
|
+
'[data-testid="run-dashboard-error"]',
|
|
6977
|
+
(0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.getApiErrorMessage)(error, 'Failed to refresh dashboard.')
|
|
6978
|
+
);
|
|
6979
|
+
} finally {
|
|
6980
|
+
if (showScopedLoading) {
|
|
6981
|
+
(0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.hideLoading)();
|
|
6982
|
+
if (refreshButton) refreshButton.disabled = false;
|
|
6983
|
+
}
|
|
6984
|
+
}
|
|
6985
|
+
}
|
|
6986
|
+
|
|
6987
|
+
function renderDashboard(data) {
|
|
6988
|
+
renderRunSummary(data.summary);
|
|
6989
|
+
renderGateMatrix(data.gate_columns, data.ticket_rows);
|
|
6990
|
+
renderActiveJobs(data.active_jobs);
|
|
6991
|
+
renderNeedsHumanCards(data.needs_human_cards, data.summary);
|
|
6992
|
+
}
|
|
6993
|
+
|
|
6994
|
+
function renderRunSummary(summary) {
|
|
6995
|
+
const summaryEl = document.querySelector('[data-testid="run-dashboard-summary"]');
|
|
6996
|
+
if (!summaryEl || !summary) return;
|
|
6997
|
+
const fieldMap = {
|
|
6998
|
+
run_status: summary.run_status,
|
|
6999
|
+
last_tick_at: summary.last_tick_at || 'N/A',
|
|
7000
|
+
active_job_count: summary.active_job_count,
|
|
7001
|
+
needs_human_count: summary.needs_human_count,
|
|
7002
|
+
lease_owner: summary.lease_owner || 'N/A',
|
|
7003
|
+
};
|
|
7004
|
+
Object.keys(fieldMap).forEach((field) => {
|
|
7005
|
+
const el = summaryEl.querySelector(`[data-field="${field}"]`);
|
|
7006
|
+
if (el) el.textContent = String(fieldMap[field]);
|
|
7007
|
+
});
|
|
7008
|
+
}
|
|
7009
|
+
|
|
7010
|
+
function renderGateMatrix(gateColumns, ticketRows) {
|
|
7011
|
+
const tbody = document.getElementById('gate-matrix-body');
|
|
7012
|
+
const emptyState = document.querySelector('[data-testid="gate-matrix-empty"]');
|
|
7013
|
+
if (!tbody) return;
|
|
7014
|
+
|
|
7015
|
+
while (tbody.firstChild) {
|
|
7016
|
+
tbody.removeChild(tbody.firstChild);
|
|
7017
|
+
}
|
|
7018
|
+
|
|
7019
|
+
const rows = ticketRows || [];
|
|
7020
|
+
if (rows.length === 0) {
|
|
7021
|
+
if (emptyState) emptyState.classList.remove('hidden');
|
|
7022
|
+
return;
|
|
7023
|
+
}
|
|
7024
|
+
if (emptyState) emptyState.classList.add('hidden');
|
|
7025
|
+
|
|
7026
|
+
rows.forEach((row) => {
|
|
7027
|
+
const tr = document.createElement('tr');
|
|
7028
|
+
tr.dataset.testid = 'gate-matrix-row';
|
|
7029
|
+
tr.dataset.ticketKey = row.ticket_key;
|
|
7030
|
+
|
|
7031
|
+
const ticketTd = document.createElement('td');
|
|
7032
|
+
ticketTd.className = 'monospace';
|
|
7033
|
+
ticketTd.textContent = row.ticket_key;
|
|
7034
|
+
tr.appendChild(ticketTd);
|
|
7035
|
+
|
|
7036
|
+
(row.cells || []).forEach((cell) => {
|
|
7037
|
+
const td = document.createElement('td');
|
|
7038
|
+
td.className = 'gate-cell';
|
|
7039
|
+
td.dataset.testid = 'gate-cell';
|
|
7040
|
+
td.dataset.ticketKey = row.ticket_key;
|
|
7041
|
+
td.dataset.gateKey = cell.gate_key;
|
|
7042
|
+
|
|
7043
|
+
const badge = document.createElement('span');
|
|
7044
|
+
const status = cell.status || 'pending';
|
|
7045
|
+
badge.className = `gate-status-badge gate-status-badge--${status}`;
|
|
7046
|
+
badge.textContent = status;
|
|
7047
|
+
td.appendChild(badge);
|
|
7048
|
+
|
|
7049
|
+
if (cell.observation_detail) {
|
|
7050
|
+
const details = document.createElement('details');
|
|
7051
|
+
details.className = 'gate-observation-detail';
|
|
7052
|
+
details.dataset.testid = 'gate-observation-detail';
|
|
7053
|
+
details.dataset.ticketKey = row.ticket_key;
|
|
7054
|
+
details.dataset.gateKey = cell.gate_key;
|
|
7055
|
+
|
|
7056
|
+
const summary = document.createElement('summary');
|
|
7057
|
+
summary.textContent = cell.observation_summary || '';
|
|
7058
|
+
details.appendChild(summary);
|
|
7059
|
+
|
|
7060
|
+
const body = document.createElement('div');
|
|
7061
|
+
body.className = 'observation-detail-body';
|
|
7062
|
+
body.textContent = cell.observation_detail;
|
|
7063
|
+
details.appendChild(body);
|
|
7064
|
+
|
|
7065
|
+
td.appendChild(details);
|
|
7066
|
+
}
|
|
7067
|
+
|
|
7068
|
+
tr.appendChild(td);
|
|
7069
|
+
});
|
|
7070
|
+
|
|
7071
|
+
tbody.appendChild(tr);
|
|
7072
|
+
});
|
|
7073
|
+
}
|
|
7074
|
+
|
|
7075
|
+
function renderActiveJobs(activeJobs) {
|
|
7076
|
+
const list = document.getElementById('active-jobs-list');
|
|
7077
|
+
const emptyState = document.querySelector('[data-testid="active-jobs-empty"]');
|
|
7078
|
+
if (!list) return;
|
|
7079
|
+
|
|
7080
|
+
while (list.firstChild) {
|
|
7081
|
+
list.removeChild(list.firstChild);
|
|
7082
|
+
}
|
|
7083
|
+
|
|
7084
|
+
const jobs = activeJobs || [];
|
|
7085
|
+
if (jobs.length === 0) {
|
|
7086
|
+
if (emptyState) emptyState.classList.remove('hidden');
|
|
7087
|
+
return;
|
|
7088
|
+
}
|
|
7089
|
+
if (emptyState) emptyState.classList.add('hidden');
|
|
7090
|
+
|
|
7091
|
+
jobs.forEach((job) => {
|
|
7092
|
+
const row = document.createElement('div');
|
|
7093
|
+
row.className = 'active-job-row';
|
|
7094
|
+
row.dataset.testid = 'active-job-row';
|
|
7095
|
+
row.dataset.jobId = String(job.id);
|
|
7096
|
+
|
|
7097
|
+
const statusBadge = document.createElement('span');
|
|
7098
|
+
statusBadge.className = `gate-status-badge gate-status-badge--${job.status}`;
|
|
7099
|
+
statusBadge.textContent = job.status;
|
|
7100
|
+
row.appendChild(statusBadge);
|
|
7101
|
+
|
|
7102
|
+
const ticketSpan = document.createElement('span');
|
|
7103
|
+
ticketSpan.className = 'monospace';
|
|
7104
|
+
ticketSpan.textContent = job.ticket_key || '';
|
|
7105
|
+
row.appendChild(ticketSpan);
|
|
7106
|
+
|
|
7107
|
+
const jobTypeSpan = document.createElement('span');
|
|
7108
|
+
jobTypeSpan.textContent = job.job_type || '';
|
|
7109
|
+
row.appendChild(jobTypeSpan);
|
|
7110
|
+
|
|
7111
|
+
if (job.phase_hint) {
|
|
7112
|
+
const phaseSpan = document.createElement('span');
|
|
7113
|
+
phaseSpan.className = 'phase-hint';
|
|
7114
|
+
phaseSpan.textContent = job.phase_hint;
|
|
7115
|
+
row.appendChild(phaseSpan);
|
|
7116
|
+
}
|
|
7117
|
+
|
|
7118
|
+
const activitySpan = document.createElement('span');
|
|
7119
|
+
activitySpan.className = 'job-activity';
|
|
7120
|
+
activitySpan.textContent = job.last_activity_at || 'N/A';
|
|
7121
|
+
row.appendChild(activitySpan);
|
|
7122
|
+
|
|
7123
|
+
const commitCountSpan = document.createElement('span');
|
|
7124
|
+
commitCountSpan.className = 'job-commit-count';
|
|
7125
|
+
const commitCount = job.local_commit_count != null ? job.local_commit_count : 0;
|
|
7126
|
+
commitCountSpan.textContent = `${commitCount} commits`;
|
|
7127
|
+
row.appendChild(commitCountSpan);
|
|
7128
|
+
|
|
7129
|
+
const shaSpan = document.createElement('span');
|
|
7130
|
+
shaSpan.className = 'monospace job-commit-sha';
|
|
7131
|
+
shaSpan.textContent = job.last_commit_sha || 'N/A';
|
|
7132
|
+
row.appendChild(shaSpan);
|
|
7133
|
+
|
|
7134
|
+
list.appendChild(row);
|
|
7135
|
+
});
|
|
7136
|
+
}
|
|
5222
7137
|
|
|
5223
|
-
|
|
7138
|
+
function renderNeedsHumanCards(cards, summary) {
|
|
7139
|
+
const list = document.getElementById('needs-human-list');
|
|
7140
|
+
const emptyState = document.querySelector('[data-testid="needs-human-empty"]');
|
|
7141
|
+
if (!list) return;
|
|
5224
7142
|
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
7143
|
+
while (list.firstChild) {
|
|
7144
|
+
list.removeChild(list.firstChild);
|
|
7145
|
+
}
|
|
5228
7146
|
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
saveChangesBtn.style.zIndex = '1000';
|
|
5236
|
-
saveChangesBtn.style.padding = '10px 20px';
|
|
5237
|
-
saveChangesBtn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
|
|
5238
|
-
}
|
|
7147
|
+
const items = cards || [];
|
|
7148
|
+
if (items.length === 0) {
|
|
7149
|
+
if (emptyState) emptyState.classList.remove('hidden');
|
|
7150
|
+
return;
|
|
7151
|
+
}
|
|
7152
|
+
if (emptyState) emptyState.classList.add('hidden');
|
|
5239
7153
|
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
7154
|
+
items.forEach((card) => {
|
|
7155
|
+
const cardEl = document.createElement('div');
|
|
7156
|
+
cardEl.className = 'needs-human-card';
|
|
7157
|
+
cardEl.dataset.testid = 'needs-human-card';
|
|
7158
|
+
cardEl.dataset.ticketKey = card.ticket_key;
|
|
7159
|
+
|
|
7160
|
+
const heading = document.createElement('h3');
|
|
7161
|
+
heading.className = 'monospace';
|
|
7162
|
+
heading.textContent = card.ticket_key;
|
|
7163
|
+
cardEl.appendChild(heading);
|
|
7164
|
+
|
|
7165
|
+
const fields = [
|
|
7166
|
+
['Parked gate', card.parked_gate_label || 'N/A'],
|
|
7167
|
+
['Reason', card.needs_human_reason || 'N/A'],
|
|
7168
|
+
['Parked at', card.parked_at || 'N/A'],
|
|
7169
|
+
['What happened', card.observation_summary || 'N/A'],
|
|
7170
|
+
['Remediation attempts', card.remediation_attempts],
|
|
7171
|
+
['No-progress attempts', card.remediation_no_progress_attempts],
|
|
7172
|
+
['Gate attempts', card.gate_attempts != null ? card.gate_attempts : 'N/A'],
|
|
7173
|
+
];
|
|
7174
|
+
fields.forEach(([label, value]) => {
|
|
7175
|
+
const fieldEl = document.createElement('div');
|
|
7176
|
+
fieldEl.className = 'needs-human-field';
|
|
7177
|
+
const strong = document.createElement('strong');
|
|
7178
|
+
strong.textContent = `${label}: `;
|
|
7179
|
+
fieldEl.appendChild(strong);
|
|
7180
|
+
fieldEl.appendChild(document.createTextNode(String(value)));
|
|
7181
|
+
cardEl.appendChild(fieldEl);
|
|
5247
7182
|
});
|
|
5248
7183
|
|
|
5249
|
-
if (
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
7184
|
+
if (card.observation_detail) {
|
|
7185
|
+
const details = document.createElement('details');
|
|
7186
|
+
details.className = 'gate-observation-detail';
|
|
7187
|
+
const detailsSummary = document.createElement('summary');
|
|
7188
|
+
detailsSummary.textContent = 'Observation detail';
|
|
7189
|
+
details.appendChild(detailsSummary);
|
|
7190
|
+
const body = document.createElement('div');
|
|
7191
|
+
body.className = 'observation-detail-body';
|
|
7192
|
+
body.textContent = card.observation_detail;
|
|
7193
|
+
details.appendChild(body);
|
|
7194
|
+
cardEl.appendChild(details);
|
|
7195
|
+
}
|
|
7196
|
+
|
|
7197
|
+
const actions = document.createElement('div');
|
|
7198
|
+
actions.className = 'needs-human-actions';
|
|
7199
|
+
|
|
7200
|
+
const unparkButton = document.createElement('button');
|
|
7201
|
+
unparkButton.type = 'button';
|
|
7202
|
+
unparkButton.className = 'btn btn-primary';
|
|
7203
|
+
unparkButton.dataset.testid = 'unpark-ticket-button';
|
|
7204
|
+
unparkButton.dataset.ticketKey = card.ticket_key;
|
|
7205
|
+
unparkButton.dataset.rowVersion = String(card.row_version);
|
|
7206
|
+
unparkButton.textContent = 'Unpark';
|
|
7207
|
+
actions.appendChild(unparkButton);
|
|
7208
|
+
|
|
7209
|
+
const secondaryAction = document.createElement('span');
|
|
7210
|
+
secondaryAction.className = 'needs-human-secondary-action';
|
|
7211
|
+
secondaryAction.setAttribute('aria-disabled', 'true');
|
|
7212
|
+
secondaryAction.textContent = 'Abandon (not available from this view)';
|
|
7213
|
+
actions.appendChild(secondaryAction);
|
|
7214
|
+
|
|
7215
|
+
cardEl.appendChild(actions);
|
|
7216
|
+
list.appendChild(cardEl);
|
|
7217
|
+
});
|
|
7218
|
+
}
|
|
5264
7219
|
|
|
5265
|
-
|
|
7220
|
+
async function handleUnparkClick(button) {
|
|
7221
|
+
const container = document.getElementById('run-dashboard-container');
|
|
7222
|
+
if (!container) return;
|
|
5266
7223
|
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
7224
|
+
const ticketKey = button.dataset.ticketKey;
|
|
7225
|
+
const rowVersion = button.dataset.rowVersion;
|
|
7226
|
+
const repoName = container.dataset.repoName;
|
|
7227
|
+
const epicRunId = container.dataset.epicRunId;
|
|
7228
|
+
const unparkUrlTemplate = container.dataset.unparkUrlTemplate;
|
|
5271
7229
|
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
7230
|
+
button.disabled = true;
|
|
7231
|
+
(0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.showLoading)();
|
|
7232
|
+
|
|
7233
|
+
try {
|
|
7234
|
+
const url = unparkUrlTemplate.replace('__ticket_key__', encodeURIComponent(ticketKey));
|
|
7235
|
+
await (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.apiCall)(url, 'POST', {
|
|
7236
|
+
repo_name: repoName,
|
|
7237
|
+
expected_row_version: Number(rowVersion),
|
|
7238
|
+
idempotency_key: `run-dashboard-unpark:${epicRunId}:${ticketKey}:${rowVersion}`,
|
|
7239
|
+
reason: 'Unparked from the run dashboard.',
|
|
7240
|
+
});
|
|
7241
|
+
await refreshDashboard({ showScopedLoading: false });
|
|
7242
|
+
} catch (error) {
|
|
7243
|
+
(0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Unpark Failed', (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.getApiErrorMessage)(error, 'Failed to unpark ticket.'));
|
|
7244
|
+
button.disabled = false;
|
|
7245
|
+
} finally {
|
|
7246
|
+
(0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.hideLoading)();
|
|
5279
7247
|
}
|
|
5280
7248
|
}
|
|
5281
7249
|
|
|
5282
7250
|
document.addEventListener('DOMContentLoaded', init);
|
|
5283
7251
|
|
|
5284
|
-
// Named exports for unit testing (DOM-free where possible). Exporting does not
|
|
5285
|
-
// change browser behavior — the page still runs via the DOMContentLoaded hook.
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
7252
|
|
|
5289
7253
|
/***/ },
|
|
5290
7254
|
|
|
@@ -6806,8 +8770,480 @@ let projectsContainer, createProjectContainer;
|
|
|
6806
8770
|
// Project Management (Setup Page Only)
|
|
6807
8771
|
// ==========================================
|
|
6808
8772
|
|
|
8773
|
+
// ------------------------------------------------------------------
|
|
8774
|
+
// BAPI-548: attention-first, XSS-safe setup dashboard renderer.
|
|
8775
|
+
//
|
|
8776
|
+
// SECURITY: every project-supplied string (display name, repo name,
|
|
8777
|
+
// description, working_in, provider, version, branch, signal labels and
|
|
8778
|
+
// messages) is assigned via textContent / safeText / createTextElement — never
|
|
8779
|
+
// interpolated into innerHTML. Static structural markup is built as DOM nodes.
|
|
8780
|
+
// ------------------------------------------------------------------
|
|
8781
|
+
|
|
8782
|
+
// Visible launcher routes (order matters — matches the comp's launcher row).
|
|
8783
|
+
const LAUNCHER_ACTIONS = [
|
|
8784
|
+
{ label: 'Setup', testid: 'project-action-setup', href: (id) => `/setup/setup-detail?projectId=${id}` },
|
|
8785
|
+
{ label: 'Configure', testid: 'project-action-configure', href: (id) => `/setup/project-detail?projectId=${id}` },
|
|
8786
|
+
{ label: 'Optimize', testid: 'project-action-optimize', href: (id) => `/optimize/${id}` },
|
|
8787
|
+
{ label: 'Data', testid: 'project-action-data', href: (id) => `/setup/data/${id}` },
|
|
8788
|
+
{ label: 'Monitoring', testid: 'project-action-monitoring', href: (id) => `/setup/monitoring/${id}` },
|
|
8789
|
+
];
|
|
8790
|
+
|
|
8791
|
+
// Overflow (⋯) routes, kept separate from the visible launcher row.
|
|
8792
|
+
const OVERFLOW_ACTIONS = [
|
|
8793
|
+
{ label: 'Security', testid: 'project-action-security', href: (id) => `/setup/security/${id}` },
|
|
8794
|
+
{ label: 'Connect editor', testid: 'project-action-connect-editor', href: (id) => `/setup/get-started?projectId=${id}` },
|
|
8795
|
+
];
|
|
8796
|
+
|
|
8797
|
+
const PROVIDER_LABELS = { openai: 'OpenAI', anthropic: 'Anthropic', gemini: 'Gemini' };
|
|
8798
|
+
const STATE_ICONS = { ok: '✓', warning: '⚠', critical: '⛔', unknown: '–' };
|
|
8799
|
+
|
|
8800
|
+
/**
|
|
8801
|
+
* Coerce a possibly-nullish value to a safe display string. Never returns HTML.
|
|
8802
|
+
* @param {*} value
|
|
8803
|
+
* @param {string} fallback
|
|
8804
|
+
* @returns {string}
|
|
8805
|
+
*/
|
|
8806
|
+
function safeText(value, fallback) {
|
|
8807
|
+
if (value === null || value === undefined) {
|
|
8808
|
+
return fallback || '';
|
|
8809
|
+
}
|
|
8810
|
+
const text = String(value).trim();
|
|
8811
|
+
return text === '' ? (fallback || '') : text;
|
|
8812
|
+
}
|
|
8813
|
+
|
|
8814
|
+
/**
|
|
8815
|
+
* Create an element, set an optional class, and assign text via textContent.
|
|
8816
|
+
* @param {string} tag
|
|
8817
|
+
* @param {string} className
|
|
8818
|
+
* @param {string} text
|
|
8819
|
+
* @returns {HTMLElement}
|
|
8820
|
+
*/
|
|
8821
|
+
function createTextElement(tag, className, text) {
|
|
8822
|
+
const el = document.createElement(tag);
|
|
8823
|
+
if (className) {
|
|
8824
|
+
el.className = className;
|
|
8825
|
+
}
|
|
8826
|
+
if (text !== undefined && text !== null) {
|
|
8827
|
+
el.textContent = String(text);
|
|
8828
|
+
}
|
|
8829
|
+
return el;
|
|
8830
|
+
}
|
|
8831
|
+
|
|
8832
|
+
/**
|
|
8833
|
+
* Format an ISO timestamp into a short relative string (e.g. "2d ago").
|
|
8834
|
+
* @param {string} iso
|
|
8835
|
+
* @returns {string}
|
|
8836
|
+
*/
|
|
8837
|
+
function formatRelativeTime(iso) {
|
|
8838
|
+
if (!iso) {
|
|
8839
|
+
return 'never';
|
|
8840
|
+
}
|
|
8841
|
+
const then = new Date(iso);
|
|
8842
|
+
if (Number.isNaN(then.getTime())) {
|
|
8843
|
+
return 'unknown';
|
|
8844
|
+
}
|
|
8845
|
+
const diffMs = Date.now() - then.getTime();
|
|
8846
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
8847
|
+
if (diffDays <= 0) {
|
|
8848
|
+
return 'today';
|
|
8849
|
+
}
|
|
8850
|
+
if (diffDays === 1) {
|
|
8851
|
+
return '1d ago';
|
|
8852
|
+
}
|
|
8853
|
+
if (diffDays < 30) {
|
|
8854
|
+
return `${diffDays}d ago`;
|
|
8855
|
+
}
|
|
8856
|
+
const diffMonths = Math.floor(diffDays / 30);
|
|
8857
|
+
if (diffMonths < 12) {
|
|
8858
|
+
return `${diffMonths}mo ago`;
|
|
8859
|
+
}
|
|
8860
|
+
return `${Math.floor(diffMonths / 12)}y ago`;
|
|
8861
|
+
}
|
|
8862
|
+
|
|
8863
|
+
/**
|
|
8864
|
+
* Tolerate a missing/older API `status` block by returning an unknown-safe default.
|
|
8865
|
+
* @param {Object} project
|
|
8866
|
+
* @returns {Object}
|
|
8867
|
+
*/
|
|
8868
|
+
function getProjectStatus(project) {
|
|
8869
|
+
const status = project && project.status ? project.status : {};
|
|
8870
|
+
return {
|
|
8871
|
+
working_in: status.working_in || null,
|
|
8872
|
+
provider: status.provider || null,
|
|
8873
|
+
version: status.version || null,
|
|
8874
|
+
last_index: status.last_index || {},
|
|
8875
|
+
signals: Array.isArray(status.signals) ? status.signals : [],
|
|
8876
|
+
summary: status.summary || { attention_count: 0, overall_state: 'unknown' },
|
|
8877
|
+
};
|
|
8878
|
+
}
|
|
8879
|
+
|
|
8880
|
+
/**
|
|
8881
|
+
* Return only the signals that need attention (warning or critical).
|
|
8882
|
+
* @param {Object} status
|
|
8883
|
+
* @returns {Array}
|
|
8884
|
+
*/
|
|
8885
|
+
function getAttentionSignals(status) {
|
|
8886
|
+
if (!status || !Array.isArray(status.signals)) {
|
|
8887
|
+
return [];
|
|
8888
|
+
}
|
|
8889
|
+
return status.signals.filter((signal) => signal && (signal.state === 'warning' || signal.state === 'critical'));
|
|
8890
|
+
}
|
|
8891
|
+
|
|
8892
|
+
/**
|
|
8893
|
+
* Update the dashboard header counters from the fetched projects.
|
|
8894
|
+
* @param {Array} projects
|
|
8895
|
+
*/
|
|
8896
|
+
function updateProjectsDashboardSummary(projects) {
|
|
8897
|
+
const list = Array.isArray(projects) ? projects : [];
|
|
8898
|
+
const countEl = document.getElementById('projects-count');
|
|
8899
|
+
const attentionEl = document.getElementById('projects-attention-count');
|
|
8900
|
+
|
|
8901
|
+
if (countEl) {
|
|
8902
|
+
const noun = list.length === 1 ? 'project' : 'projects';
|
|
8903
|
+
countEl.textContent = `${list.length} ${noun}`;
|
|
8904
|
+
}
|
|
8905
|
+
|
|
8906
|
+
if (attentionEl) {
|
|
8907
|
+
// Header counts PROJECTS that need attention (≥1 warning/critical signal),
|
|
8908
|
+
// not total signals — the per-card pill carries the per-project signal count.
|
|
8909
|
+
const projectsNeedingAttention = list.reduce((sum, project) => {
|
|
8910
|
+
const status = getProjectStatus(project);
|
|
8911
|
+
return sum + (getAttentionSignals(status).length > 0 ? 1 : 0);
|
|
8912
|
+
}, 0);
|
|
8913
|
+
if (projectsNeedingAttention > 0) {
|
|
8914
|
+
attentionEl.textContent = ` · ${projectsNeedingAttention} need attention`;
|
|
8915
|
+
attentionEl.classList.remove('hidden');
|
|
8916
|
+
} else {
|
|
8917
|
+
attentionEl.textContent = '';
|
|
8918
|
+
attentionEl.classList.add('hidden');
|
|
8919
|
+
}
|
|
8920
|
+
}
|
|
8921
|
+
}
|
|
8922
|
+
|
|
8923
|
+
/**
|
|
8924
|
+
* Build the compact one-line summary row: dot, display name, repo tag, status pill.
|
|
8925
|
+
* @param {Object} project
|
|
8926
|
+
* @param {Object} status
|
|
8927
|
+
* @returns {HTMLElement}
|
|
8928
|
+
*/
|
|
8929
|
+
function buildProjectSummaryRow(project, status) {
|
|
8930
|
+
const overallState = (status.summary && status.summary.overall_state) || 'unknown';
|
|
8931
|
+
const attentionCount = getAttentionSignals(status).length;
|
|
8932
|
+
|
|
8933
|
+
const row = createTextElement('div', 'project-card-summary');
|
|
8934
|
+
|
|
8935
|
+
const identity = createTextElement('div', 'project-identity');
|
|
8936
|
+
identity.appendChild(createTextElement('span', `project-state-dot project-state-dot--${overallState}`, ''));
|
|
8937
|
+
const title = createTextElement('span', 'project-title', safeText(project.repo_display_name || project.repo_name, 'Untitled project'));
|
|
8938
|
+
title.setAttribute('data-testid', 'project-display-name');
|
|
8939
|
+
identity.appendChild(title);
|
|
8940
|
+
const repoTag = createTextElement('span', 'project-repo-tag', safeText(project.repo_name, ''));
|
|
8941
|
+
repoTag.setAttribute('data-testid', 'project-repo-tag');
|
|
8942
|
+
identity.appendChild(repoTag);
|
|
8943
|
+
row.appendChild(identity);
|
|
8944
|
+
|
|
8945
|
+
let pill;
|
|
8946
|
+
if (attentionCount > 0) {
|
|
8947
|
+
pill = createTextElement('span', 'project-status-pill project-status-pill--attention', `⚠ ${attentionCount} need attention`);
|
|
8948
|
+
} else {
|
|
8949
|
+
pill = createTextElement('span', 'project-status-pill project-status-pill--ok', '✓ All monitored checks green');
|
|
8950
|
+
}
|
|
8951
|
+
pill.setAttribute('data-testid', 'project-status-pill');
|
|
8952
|
+
row.appendChild(pill);
|
|
8953
|
+
return row;
|
|
8954
|
+
}
|
|
8955
|
+
|
|
8956
|
+
/**
|
|
8957
|
+
* Build the muted secondary meta line. Absent values are skipped or dashed.
|
|
8958
|
+
* @param {Object} status
|
|
8959
|
+
* @returns {HTMLElement}
|
|
8960
|
+
*/
|
|
8961
|
+
function buildProjectMetaLine(status) {
|
|
8962
|
+
const parts = [];
|
|
8963
|
+
if (status.working_in) {
|
|
8964
|
+
parts.push(`works in ${safeText(status.working_in, '')}`);
|
|
8965
|
+
}
|
|
8966
|
+
if (status.version) {
|
|
8967
|
+
parts.push(`v${safeText(status.version, '')}`);
|
|
8968
|
+
}
|
|
8969
|
+
if (status.provider) {
|
|
8970
|
+
parts.push(`AI ${PROVIDER_LABELS[status.provider] || safeText(status.provider, '')}`);
|
|
8971
|
+
}
|
|
8972
|
+
const lastIndex = status.last_index || {};
|
|
8973
|
+
if (lastIndex.last_ran) {
|
|
8974
|
+
parts.push(`indexed ${safeText(lastIndex.branch, 'default')}`);
|
|
8975
|
+
parts.push(formatRelativeTime(lastIndex.last_ran));
|
|
8976
|
+
} else {
|
|
8977
|
+
parts.push('never indexed');
|
|
8978
|
+
}
|
|
8979
|
+
const meta = createTextElement('div', 'project-meta-line', parts.join(' · '));
|
|
8980
|
+
meta.setAttribute('data-testid', 'project-meta-line');
|
|
8981
|
+
return meta;
|
|
8982
|
+
}
|
|
8983
|
+
|
|
8984
|
+
/**
|
|
8985
|
+
* Build the 2-line-clamped description text node.
|
|
8986
|
+
* @param {Object} project
|
|
8987
|
+
* @returns {HTMLElement}
|
|
8988
|
+
*/
|
|
8989
|
+
function buildProjectDescription(project) {
|
|
8990
|
+
const description = createTextElement('p', 'project-description', safeText(project.project_description, 'No description provided.'));
|
|
8991
|
+
description.setAttribute('data-testid', 'project-description');
|
|
8992
|
+
return description;
|
|
8993
|
+
}
|
|
8994
|
+
|
|
8995
|
+
/**
|
|
8996
|
+
* Build a safe internal fix link for an attention panel.
|
|
8997
|
+
* Only internal routes beginning with "/" become navigable links.
|
|
8998
|
+
* @param {Object} fix
|
|
8999
|
+
* @returns {HTMLElement}
|
|
9000
|
+
*/
|
|
9001
|
+
function buildAttentionFixLink(fix) {
|
|
9002
|
+
const label = fix && fix.label ? `${safeText(fix.label, 'go')} →` : '→ go';
|
|
9003
|
+
const href = fix && typeof fix.href === 'string' ? fix.href : '';
|
|
9004
|
+
if (href && href.charAt(0) === '/' && href.charAt(1) !== '/') {
|
|
9005
|
+
const link = createTextElement('a', 'attention-fix-link', label);
|
|
9006
|
+
link.setAttribute('href', href);
|
|
9007
|
+
link.setAttribute('data-testid', 'attention-fix-link');
|
|
9008
|
+
return link;
|
|
9009
|
+
}
|
|
9010
|
+
// Non-internal / missing href: render as inert text, never a navigable link.
|
|
9011
|
+
const inert = createTextElement('span', 'attention-fix-link attention-fix-link--inert', label);
|
|
9012
|
+
inert.setAttribute('data-testid', 'attention-fix-link');
|
|
9013
|
+
return inert;
|
|
9014
|
+
}
|
|
9015
|
+
|
|
9016
|
+
/**
|
|
9017
|
+
* Build tinted attention panels for warning/critical signals.
|
|
9018
|
+
* @param {Array} signals - attention signals only
|
|
9019
|
+
* @returns {HTMLElement|null}
|
|
9020
|
+
*/
|
|
9021
|
+
function buildAttentionPanels(signals) {
|
|
9022
|
+
if (!signals || signals.length === 0) {
|
|
9023
|
+
return null;
|
|
9024
|
+
}
|
|
9025
|
+
const list = createTextElement('div', 'project-attention-list', '');
|
|
9026
|
+
signals.forEach((signal) => {
|
|
9027
|
+
const state = signal.state === 'critical' ? 'critical' : 'warning';
|
|
9028
|
+
const panel = createTextElement('div', `attention-panel attention-panel--${state}`, '');
|
|
9029
|
+
panel.setAttribute('data-testid', 'attention-panel');
|
|
9030
|
+
panel.setAttribute('data-signal-id', safeText(signal.id, ''));
|
|
9031
|
+
|
|
9032
|
+
const icon = createTextElement('span', 'attention-icon', STATE_ICONS[state] || '⚠');
|
|
9033
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
9034
|
+
panel.appendChild(icon);
|
|
9035
|
+
|
|
9036
|
+
const content = createTextElement('div', 'attention-content', '');
|
|
9037
|
+
content.appendChild(createTextElement('span', 'attention-label', safeText(signal.label, 'Check')));
|
|
9038
|
+
content.appendChild(createTextElement('span', 'attention-message', safeText(signal.message, '')));
|
|
9039
|
+
panel.appendChild(content);
|
|
9040
|
+
|
|
9041
|
+
panel.appendChild(buildAttentionFixLink(signal.fix));
|
|
9042
|
+
list.appendChild(panel);
|
|
9043
|
+
});
|
|
9044
|
+
return list;
|
|
9045
|
+
}
|
|
9046
|
+
|
|
9047
|
+
/**
|
|
9048
|
+
* Build the native, keyboard-operable "Show all checks" disclosure listing every signal.
|
|
9049
|
+
* @param {Array} signals - all signals
|
|
9050
|
+
* @returns {HTMLElement|null}
|
|
9051
|
+
*/
|
|
9052
|
+
function buildChecksDetails(signals) {
|
|
9053
|
+
if (!signals || signals.length === 0) {
|
|
9054
|
+
return null;
|
|
9055
|
+
}
|
|
9056
|
+
const details = createTextElement('details', 'project-checks-details', '');
|
|
9057
|
+
details.setAttribute('data-testid', 'checks-details');
|
|
9058
|
+
const summary = createTextElement('summary', 'project-checks-summary', 'Show all checks');
|
|
9059
|
+
summary.setAttribute('data-testid', 'checks-summary');
|
|
9060
|
+
details.appendChild(summary);
|
|
9061
|
+
|
|
9062
|
+
const rows = createTextElement('div', 'project-checks-list', '');
|
|
9063
|
+
rows.setAttribute('data-testid', 'checks-list');
|
|
9064
|
+
signals.forEach((signal) => {
|
|
9065
|
+
const state = signal.state || 'unknown';
|
|
9066
|
+
const row = createTextElement('div', `project-check-row project-check-row--${state}`, '');
|
|
9067
|
+
row.setAttribute('data-testid', 'check-row');
|
|
9068
|
+
row.setAttribute('data-signal-id', safeText(signal.id, ''));
|
|
9069
|
+
const icon = createTextElement('span', 'project-check-icon', STATE_ICONS[state] || '–');
|
|
9070
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
9071
|
+
row.appendChild(icon);
|
|
9072
|
+
row.appendChild(createTextElement('span', 'project-check-label', safeText(signal.label, 'Check')));
|
|
9073
|
+
row.appendChild(createTextElement('span', 'project-check-message', safeText(signal.message, '')));
|
|
9074
|
+
rows.appendChild(row);
|
|
9075
|
+
});
|
|
9076
|
+
details.appendChild(rows);
|
|
9077
|
+
return details;
|
|
9078
|
+
}
|
|
9079
|
+
|
|
9080
|
+
/**
|
|
9081
|
+
* Wire the delete button to the existing type-to-confirm modal flow.
|
|
9082
|
+
* @param {HTMLElement} button
|
|
9083
|
+
* @param {Object} project
|
|
9084
|
+
*/
|
|
9085
|
+
function bindDeleteProjectAction(button, project) {
|
|
9086
|
+
button.addEventListener('click', () => {
|
|
9087
|
+
const projectName = project.repo_name;
|
|
9088
|
+
if (projectName && typeof projectName === 'string' && projectName.trim() !== '') {
|
|
9089
|
+
(0,_delete_modal_js__WEBPACK_IMPORTED_MODULE_3__.showDeleteModal)(projectName, async () => {
|
|
9090
|
+
const result = await (0,_delete_modal_js__WEBPACK_IMPORTED_MODULE_3__.deleteProjectById)(project.id);
|
|
9091
|
+
if (result) {
|
|
9092
|
+
const card = document.querySelector(`.project-card[data-project-id="${project.id}"]`);
|
|
9093
|
+
if (card) {
|
|
9094
|
+
card.remove();
|
|
9095
|
+
}
|
|
9096
|
+
refreshDashboardAfterDelete();
|
|
9097
|
+
} else {
|
|
9098
|
+
(0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Error', 'Failed to delete project.');
|
|
9099
|
+
}
|
|
9100
|
+
}, () => {
|
|
9101
|
+
// Cancelled — no-op.
|
|
9102
|
+
});
|
|
9103
|
+
} else {
|
|
9104
|
+
(0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Error', 'Could not delete your project. Try deleting from the database directly.');
|
|
9105
|
+
}
|
|
9106
|
+
});
|
|
9107
|
+
}
|
|
9108
|
+
|
|
9109
|
+
/**
|
|
9110
|
+
* After a card is removed, refresh counters from the remaining cards and fall back
|
|
9111
|
+
* to the empty state when the last project is deleted.
|
|
9112
|
+
*/
|
|
9113
|
+
function refreshDashboardAfterDelete() {
|
|
9114
|
+
const projectsList = document.getElementById('projects-list');
|
|
9115
|
+
if (!projectsList) {
|
|
9116
|
+
return;
|
|
9117
|
+
}
|
|
9118
|
+
const remaining = projectsList.querySelectorAll('.project-card');
|
|
9119
|
+
const countEl = document.getElementById('projects-count');
|
|
9120
|
+
const attentionEl = document.getElementById('projects-attention-count');
|
|
9121
|
+
if (countEl) {
|
|
9122
|
+
const noun = remaining.length === 1 ? 'project' : 'projects';
|
|
9123
|
+
countEl.textContent = `${remaining.length} ${noun}`;
|
|
9124
|
+
}
|
|
9125
|
+
if (attentionEl) {
|
|
9126
|
+
// Recompute the header attention count from the REMAINING cards rather than
|
|
9127
|
+
// blindly clearing it — a card needs attention when its root carries the
|
|
9128
|
+
// warning/critical state class (same basis as updateProjectsDashboardSummary).
|
|
9129
|
+
const attentionProjects = projectsList.querySelectorAll(
|
|
9130
|
+
'.project-card--warning, .project-card--critical').length;
|
|
9131
|
+
if (attentionProjects > 0) {
|
|
9132
|
+
attentionEl.textContent = ` · ${attentionProjects} need attention`;
|
|
9133
|
+
attentionEl.classList.remove('hidden');
|
|
9134
|
+
} else {
|
|
9135
|
+
attentionEl.textContent = '';
|
|
9136
|
+
attentionEl.classList.add('hidden');
|
|
9137
|
+
}
|
|
9138
|
+
}
|
|
9139
|
+
if (remaining.length === 0) {
|
|
9140
|
+
projectsList.textContent = '';
|
|
9141
|
+
projectsList.appendChild(buildEmptyDashboardState());
|
|
9142
|
+
}
|
|
9143
|
+
}
|
|
9144
|
+
|
|
9145
|
+
/**
|
|
9146
|
+
* Build the bottom launcher row plus the keyboard-operable ⋯ overflow.
|
|
9147
|
+
* @param {Object} project
|
|
9148
|
+
* @returns {HTMLElement}
|
|
9149
|
+
*/
|
|
9150
|
+
function buildLauncherRow(project) {
|
|
9151
|
+
const footer = createTextElement('div', 'project-actions-footer', '');
|
|
9152
|
+
|
|
9153
|
+
const launcher = createTextElement('div', 'project-launcher', '');
|
|
9154
|
+
LAUNCHER_ACTIONS.forEach((action) => {
|
|
9155
|
+
const link = createTextElement('a', 'project-launcher-link', action.label);
|
|
9156
|
+
link.setAttribute('href', action.href(project.id));
|
|
9157
|
+
link.setAttribute('data-testid', action.testid);
|
|
9158
|
+
launcher.appendChild(link);
|
|
9159
|
+
});
|
|
9160
|
+
footer.appendChild(launcher);
|
|
9161
|
+
|
|
9162
|
+
const overflow = createTextElement('details', 'project-overflow', '');
|
|
9163
|
+
overflow.setAttribute('data-testid', 'project-overflow');
|
|
9164
|
+
const overflowSummary = createTextElement('summary', 'project-overflow-summary', '⋯');
|
|
9165
|
+
overflowSummary.setAttribute('aria-label', 'More actions');
|
|
9166
|
+
overflowSummary.setAttribute('data-testid', 'project-overflow-summary');
|
|
9167
|
+
overflow.appendChild(overflowSummary);
|
|
9168
|
+
|
|
9169
|
+
const menu = createTextElement('div', 'project-overflow-menu', '');
|
|
9170
|
+
OVERFLOW_ACTIONS.forEach((action) => {
|
|
9171
|
+
const link = createTextElement('a', 'project-overflow-action', action.label);
|
|
9172
|
+
link.setAttribute('href', action.href(project.id));
|
|
9173
|
+
link.setAttribute('data-testid', action.testid);
|
|
9174
|
+
menu.appendChild(link);
|
|
9175
|
+
});
|
|
9176
|
+
const deleteButton = createTextElement('button', 'project-overflow-action project-overflow-action--delete', 'Delete');
|
|
9177
|
+
deleteButton.setAttribute('type', 'button');
|
|
9178
|
+
deleteButton.setAttribute('data-testid', 'project-action-delete');
|
|
9179
|
+
bindDeleteProjectAction(deleteButton, project);
|
|
9180
|
+
menu.appendChild(deleteButton);
|
|
9181
|
+
|
|
9182
|
+
overflow.appendChild(menu);
|
|
9183
|
+
footer.appendChild(overflow);
|
|
9184
|
+
return footer;
|
|
9185
|
+
}
|
|
9186
|
+
|
|
9187
|
+
/**
|
|
9188
|
+
* Build a single attention-first project card as DOM nodes.
|
|
9189
|
+
* @param {Object} project
|
|
9190
|
+
* @returns {HTMLElement}
|
|
9191
|
+
*/
|
|
9192
|
+
function buildProjectCard(project) {
|
|
9193
|
+
const status = getProjectStatus(project);
|
|
9194
|
+
const overallState = (status.summary && status.summary.overall_state) || 'unknown';
|
|
9195
|
+
const attentionSignals = getAttentionSignals(status);
|
|
9196
|
+
|
|
9197
|
+
const card = createTextElement('div', `project-card project-card--${overallState}`, '');
|
|
9198
|
+
card.setAttribute('data-testid', 'project-card');
|
|
9199
|
+
card.setAttribute('data-project-id', project.id);
|
|
9200
|
+
card.setAttribute('data-project-name', safeText(project.repo_name, ''));
|
|
9201
|
+
|
|
9202
|
+
card.appendChild(buildProjectSummaryRow(project, status));
|
|
9203
|
+
card.appendChild(buildProjectMetaLine(status));
|
|
9204
|
+
card.appendChild(buildProjectDescription(project));
|
|
9205
|
+
|
|
9206
|
+
const panels = buildAttentionPanels(attentionSignals);
|
|
9207
|
+
if (panels) {
|
|
9208
|
+
card.appendChild(panels);
|
|
9209
|
+
}
|
|
9210
|
+
|
|
9211
|
+
const checks = buildChecksDetails(status.signals);
|
|
9212
|
+
if (checks) {
|
|
9213
|
+
card.appendChild(checks);
|
|
9214
|
+
}
|
|
9215
|
+
|
|
9216
|
+
card.appendChild(buildLauncherRow(project));
|
|
9217
|
+
return card;
|
|
9218
|
+
}
|
|
9219
|
+
|
|
9220
|
+
/**
|
|
9221
|
+
* Build the calm empty-dashboard state as DOM nodes.
|
|
9222
|
+
* @returns {HTMLElement}
|
|
9223
|
+
*/
|
|
9224
|
+
function buildEmptyDashboardState() {
|
|
9225
|
+
const wrap = createTextElement('div', 'setup-empty-state', '');
|
|
9226
|
+
wrap.setAttribute('data-testid', 'setup-empty-state');
|
|
9227
|
+
wrap.appendChild(createTextElement('p', 'setup-empty-title', 'No projects yet'));
|
|
9228
|
+
wrap.appendChild(createTextElement('p', 'setup-empty-message', 'Create your first project to start monitoring its health here.'));
|
|
9229
|
+
return wrap;
|
|
9230
|
+
}
|
|
9231
|
+
|
|
9232
|
+
/**
|
|
9233
|
+
* Build the inline error state for a failed project-list fetch.
|
|
9234
|
+
* @param {string} message
|
|
9235
|
+
* @returns {HTMLElement}
|
|
9236
|
+
*/
|
|
9237
|
+
function buildDashboardErrorState(message) {
|
|
9238
|
+
const wrap = createTextElement('div', 'setup-error-state', '');
|
|
9239
|
+
wrap.setAttribute('data-testid', 'setup-error-state');
|
|
9240
|
+
wrap.appendChild(createTextElement('p', 'setup-error-title', 'Could not load your projects'));
|
|
9241
|
+
wrap.appendChild(createTextElement('p', 'setup-error-message', safeText(message, 'Please try again.')));
|
|
9242
|
+
return wrap;
|
|
9243
|
+
}
|
|
9244
|
+
|
|
6809
9245
|
/**
|
|
6810
|
-
* Loads the list of projects
|
|
9246
|
+
* Loads the list of projects and renders the dashboard.
|
|
6811
9247
|
*/
|
|
6812
9248
|
async function loadProjects() {
|
|
6813
9249
|
(0,_utils_js__WEBPACK_IMPORTED_MODULE_2__.showLoading)();
|
|
@@ -6820,135 +9256,38 @@ async function loadProjects() {
|
|
|
6820
9256
|
}
|
|
6821
9257
|
} catch (error) {
|
|
6822
9258
|
console.error('Error loading projects:', error);
|
|
6823
|
-
|
|
9259
|
+
const projectsList = document.getElementById('projects-list');
|
|
9260
|
+
if (projectsList) {
|
|
9261
|
+
projectsList.textContent = '';
|
|
9262
|
+
projectsList.appendChild(buildDashboardErrorState((0,_utils_js__WEBPACK_IMPORTED_MODULE_2__.getApiErrorMessage)(error, 'Failed to load projects.')));
|
|
9263
|
+
}
|
|
9264
|
+
updateProjectsDashboardSummary([]);
|
|
6824
9265
|
} finally {
|
|
6825
9266
|
(0,_utils_js__WEBPACK_IMPORTED_MODULE_2__.hideLoading)();
|
|
6826
9267
|
}
|
|
6827
9268
|
}
|
|
6828
9269
|
|
|
6829
9270
|
/**
|
|
6830
|
-
* Renders the list of projects in the projects grid
|
|
9271
|
+
* Renders the list of projects in the projects grid using safe DOM construction.
|
|
6831
9272
|
* @param {Array} projects - List of projects
|
|
6832
9273
|
*/
|
|
6833
9274
|
function renderProjectsList(projects) {
|
|
6834
9275
|
const projectsList = document.getElementById('projects-list');
|
|
6835
|
-
projectsList
|
|
6836
|
-
|
|
6837
|
-
if (projects.length === 0) {
|
|
6838
|
-
projectsList.innerHTML = '<p>No projects found. Create one!</p>';
|
|
9276
|
+
if (!projectsList) {
|
|
6839
9277
|
return;
|
|
6840
9278
|
}
|
|
6841
|
-
|
|
6842
|
-
projects.forEach(project => {
|
|
6843
|
-
const displayName = project.repo_display_name || project.repo_name;
|
|
6844
|
-
const projectCard = document.createElement('div');
|
|
6845
|
-
projectCard.className = 'project-card';
|
|
6846
|
-
projectCard.setAttribute('data-project-id', project.id);
|
|
6847
|
-
projectCard.setAttribute('data-project-name', project.repo_name);
|
|
6848
|
-
projectCard.innerHTML = `
|
|
6849
|
-
<h3>${displayName}</h3>
|
|
6850
|
-
<p>${project.project_description || 'No description provided.'}</p>
|
|
6851
|
-
<div class="project-card-actions">
|
|
6852
|
-
<div class="project-card-actions-row">
|
|
6853
|
-
<button class="btn btn-secondary btn-setup" data-project-id="${project.id}">Setup</button>
|
|
6854
|
-
<button class="btn btn-secondary btn-config" data-project-id="${project.id}">Configure</button>
|
|
6855
|
-
<button class="btn btn-secondary btn-optimize" data-project-id="${project.id}">Optimize</button>
|
|
6856
|
-
<button class="btn btn-secondary btn-health">Data</button>
|
|
6857
|
-
<button class="btn btn-secondary btn-monitoring">Monitoring</button>
|
|
6858
|
-
<button class="btn btn-secondary btn-security" data-project-id="${project.id}">Security</button>
|
|
6859
|
-
<button class="btn btn-secondary btn-connect-editor" data-project-id="${project.id}">Connect editor</button>
|
|
6860
|
-
</div>
|
|
6861
|
-
<div class="project-card-actions-row project-card-actions-secondary">
|
|
6862
|
-
<a href="#" class="delete-link" data-project-id="${project.id}">Delete</a>
|
|
6863
|
-
</div>
|
|
6864
|
-
</div>
|
|
6865
|
-
`;
|
|
6866
|
-
|
|
6867
|
-
// Add event listener for the setup button
|
|
6868
|
-
const setupButton = projectCard.querySelector('.btn-setup');
|
|
6869
|
-
if (setupButton) {
|
|
6870
|
-
setupButton.addEventListener('click', () => {
|
|
6871
|
-
window.location.href = `/setup/setup-detail?projectId=${project.id}`;
|
|
6872
|
-
});
|
|
6873
|
-
}
|
|
6874
|
-
|
|
6875
|
-
// Add event listener for the config button
|
|
6876
|
-
const configButton = projectCard.querySelector('.btn-config');
|
|
6877
|
-
if (configButton) {
|
|
6878
|
-
configButton.addEventListener('click', () => {
|
|
6879
|
-
window.location.href = `/setup/project-detail?projectId=${project.id}`;
|
|
6880
|
-
});
|
|
6881
|
-
}
|
|
6882
|
-
|
|
6883
|
-
// Add event listener for the optimize button
|
|
6884
|
-
const optimizeButton = projectCard.querySelector('.btn-optimize');
|
|
6885
|
-
if (optimizeButton) {
|
|
6886
|
-
optimizeButton.addEventListener('click', () => {
|
|
6887
|
-
window.location.href = `/optimize/${project.id}`;
|
|
6888
|
-
});
|
|
6889
|
-
}
|
|
6890
|
-
|
|
6891
|
-
// Add event listener for the data health button
|
|
6892
|
-
const healthButton = projectCard.querySelector('.btn-health');
|
|
6893
|
-
if (healthButton) {
|
|
6894
|
-
healthButton.addEventListener('click', () => {
|
|
6895
|
-
window.location.href = `/setup/data/${project.id}`;
|
|
6896
|
-
});
|
|
6897
|
-
}
|
|
6898
|
-
|
|
6899
|
-
// Add event listener for the monitoring button
|
|
6900
|
-
const monitoringButton = projectCard.querySelector('.btn-monitoring');
|
|
6901
|
-
if (monitoringButton) {
|
|
6902
|
-
monitoringButton.addEventListener('click', () => {
|
|
6903
|
-
window.location.href = `/setup/monitoring/${project.id}`;
|
|
6904
|
-
});
|
|
6905
|
-
}
|
|
6906
|
-
|
|
6907
|
-
// Add event listener for the security button
|
|
6908
|
-
const securityButton = projectCard.querySelector('.btn-security');
|
|
6909
|
-
if (securityButton) {
|
|
6910
|
-
securityButton.addEventListener('click', () => {
|
|
6911
|
-
window.location.href = `/setup/security/${project.id}`;
|
|
6912
|
-
});
|
|
6913
|
-
}
|
|
9279
|
+
projectsList.textContent = ''; // Clear existing projects safely (no innerHTML).
|
|
6914
9280
|
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
if (connectEditorButton) {
|
|
6918
|
-
connectEditorButton.addEventListener('click', () => {
|
|
6919
|
-
window.location.href = `/setup/get-started?projectId=${project.id}`;
|
|
6920
|
-
});
|
|
6921
|
-
}
|
|
9281
|
+
const list = Array.isArray(projects) ? projects : [];
|
|
9282
|
+
updateProjectsDashboardSummary(list);
|
|
6922
9283
|
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
6926
|
-
|
|
6927
|
-
|
|
6928
|
-
|
|
6929
|
-
|
|
6930
|
-
(0,_delete_modal_js__WEBPACK_IMPORTED_MODULE_3__.showDeleteModal)(projectName, async () => {
|
|
6931
|
-
// Call API to delete the project
|
|
6932
|
-
const result = await (0,_delete_modal_js__WEBPACK_IMPORTED_MODULE_3__.deleteProjectById)(project.id);
|
|
6933
|
-
if (result) {
|
|
6934
|
-
// Remove the project card from the DOM
|
|
6935
|
-
const card = document.querySelector(`.project-card[data-project-id="${project.id}"]`);
|
|
6936
|
-
if (card) {
|
|
6937
|
-
card.remove();
|
|
6938
|
-
}
|
|
6939
|
-
} else {
|
|
6940
|
-
(0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Error', 'Failed to delete project.');
|
|
6941
|
-
}
|
|
6942
|
-
}, () => {
|
|
6943
|
-
// Cancelled
|
|
6944
|
-
});
|
|
6945
|
-
} else {
|
|
6946
|
-
(0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Error', 'Could not delete your project. Try deleting from the database directly.');
|
|
6947
|
-
}
|
|
6948
|
-
});
|
|
6949
|
-
}
|
|
6950
|
-
|
|
6951
|
-
projectsList.appendChild(projectCard);
|
|
9284
|
+
if (list.length === 0) {
|
|
9285
|
+
projectsList.appendChild(buildEmptyDashboardState());
|
|
9286
|
+
return;
|
|
9287
|
+
}
|
|
9288
|
+
|
|
9289
|
+
list.forEach((project) => {
|
|
9290
|
+
projectsList.appendChild(buildProjectCard(project));
|
|
6952
9291
|
});
|
|
6953
9292
|
}
|
|
6954
9293
|
|
|
@@ -7896,6 +10235,7 @@ function initDrawers(options = {}) {
|
|
|
7896
10235
|
/******/ __webpack_require__("./src/js/setup.js");
|
|
7897
10236
|
/******/ __webpack_require__("./src/js/setup-detail.js");
|
|
7898
10237
|
/******/ __webpack_require__("./src/js/security.js");
|
|
10238
|
+
/******/ __webpack_require__("./src/js/run-dashboard.js");
|
|
7899
10239
|
/******/ __webpack_require__("./src/js/project-detail.js");
|
|
7900
10240
|
/******/ __webpack_require__("./src/js/optimize_analysis.js");
|
|
7901
10241
|
/******/ __webpack_require__("./src/js/optimize.js");
|
|
@@ -7903,6 +10243,7 @@ function initDrawers(options = {}) {
|
|
|
7903
10243
|
/******/ __webpack_require__("./src/js/modal.js");
|
|
7904
10244
|
/******/ __webpack_require__("./src/js/login.js");
|
|
7905
10245
|
/******/ __webpack_require__("./src/js/learn_repository.js");
|
|
10246
|
+
/******/ __webpack_require__("./src/js/get-started.js");
|
|
7906
10247
|
/******/ __webpack_require__("./src/js/delete-modal.js");
|
|
7907
10248
|
/******/ __webpack_require__("./src/js/data_health.js");
|
|
7908
10249
|
/******/ __webpack_require__("./src/js/auth.js");
|
|
@@ -7913,11 +10254,13 @@ function initDrawers(options = {}) {
|
|
|
7913
10254
|
/******/ __webpack_require__("./src/js/optimize/context.js");
|
|
7914
10255
|
/******/ __webpack_require__("./src/css/setup.css");
|
|
7915
10256
|
/******/ __webpack_require__("./src/css/setup-detail.css");
|
|
10257
|
+
/******/ __webpack_require__("./src/css/run-dashboard.css");
|
|
7916
10258
|
/******/ __webpack_require__("./src/css/public-pages.css");
|
|
7917
10259
|
/******/ __webpack_require__("./src/css/project-detail.css");
|
|
7918
10260
|
/******/ __webpack_require__("./src/css/optimize_analysis.css");
|
|
7919
10261
|
/******/ __webpack_require__("./src/css/optimize.css");
|
|
7920
10262
|
/******/ __webpack_require__("./src/css/globals.css");
|
|
10263
|
+
/******/ __webpack_require__("./src/css/get-started.css");
|
|
7921
10264
|
/******/ var __webpack_exports__ = __webpack_require__("./src/css/data_health.css");
|
|
7922
10265
|
/******/
|
|
7923
10266
|
/******/ })()
|