diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index b4be9e297..66cabfd12 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -106,6 +106,7 @@ ## Integrations - **JIRA instances now support Jira Cloud (`*.atlassian.net`), not just Server / Data Center.** The JIRA client authenticated every instance with `Authorization: Bearer ` — correct for a Server/DC Personal Access Token, but Jira Cloud rejects that: a Cloud personal API token must be sent as HTTP Basic (`base64("email:token")`). PortOS now picks the scheme per instance by host — a `*.atlassian.net` base URL authenticates as Basic using the instance's email + API token (both already stored and required by the instance form), and any other host keeps the Bearer PAT — so Cloud and Server instances work side by side with no new fields or migration. Create a Cloud API token at id.atlassian.com → Security → API tokens and paste it into the instance's token field. (`server/services/jira.js`) +- **Configuring an app's JIRA integration now auto-detects the workspace instead of hand-typing ids.** When you pick a JIRA instance + project in an app's JIRA settings, PortOS now detects that project's **boards** and offers them as a dropdown (`id — name (type)`) rather than a free-text "Board ID" field — the field where a board id silently went stale. If the app's saved board id isn't among the project's live boards (e.g. carried over from a Server instance after a Cloud migration, where ids are reassigned), it's flagged with an amber "pick a current one" warning; the selected board also shows its current **active sprint** as confirmation. The **Epic Key** field gained live search-by-name (type a name → pick from matching epics) and validates a typed/pasted key against the instance — confirming it resolves as an Epic, or warning when it's the wrong type or no longer resolves. New read-only endpoints back this: `GET /jira/instances/:id/projects/:key/boards` and `GET /jira/instances/:id/issues/:key`. (`server/services/jira.js`, `server/routes/jira.js`, `client/src/components/apps/EditAppDrawer.jsx`) ## Internal diff --git a/client/src/components/apps/EditAppDrawer.jsx b/client/src/components/apps/EditAppDrawer.jsx index b5a72206f..2ba308358 100644 --- a/client/src/components/apps/EditAppDrawer.jsx +++ b/client/src/components/apps/EditAppDrawer.jsx @@ -40,6 +40,10 @@ const PORT_FIELDS = [ ['tlsPort', 'TLS Port'] ]; +// A JIRA issue key like CONTECH-1553 — used to tell "user typed a key to validate" +// from "user is searching an epic by name" in the epic field. +const EPIC_KEY_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/; + export default function EditAppDrawer({ app, onClose, onSave }) { const [activeTab, setActiveTab] = useDrawerTab('appTab', 'general', TAB_IDS); const [formData, setFormData] = useState({ @@ -112,6 +116,12 @@ export default function EditAppDrawer({ app, onClose, onSave }) { const [loadingProjects, setLoadingProjects] = useState(false); const [projectSearch, setProjectSearch] = useState(''); const [projectDropdownOpen, setProjectDropdownOpen] = useState(false); + const [jiraBoards, setJiraBoards] = useState([]); + const [loadingBoards, setLoadingBoards] = useState(false); + const [boardSprints, setBoardSprints] = useState([]); + const [epicResults, setEpicResults] = useState([]); + const [epicDropdownOpen, setEpicDropdownOpen] = useState(false); + const [epicValidation, setEpicValidation] = useState({ state: 'idle' }); useEffect(() => { const toInstances = (data) => data?.instances ? Object.values(data.instances) : []; @@ -149,6 +159,72 @@ export default function EditAppDrawer({ app, onClose, onSave }) { } }, [formData.jiraInstanceId, jiraInstances, formData.jiraAssignee]); + // Detect the project's agile boards so the boardId is picked from live data + // instead of hand-typed (which is how a boardId goes stale across a migration). + useEffect(() => { + if (!formData.jiraInstanceId || !formData.jiraProjectKey) { + setJiraBoards([]); + return; + } + let cancelled = false; + setLoadingBoards(true); + api.getJiraBoards(formData.jiraInstanceId, formData.jiraProjectKey, { silent: true }) + .then(boards => { if (!cancelled) setJiraBoards(boards || []); }) + .catch(() => { if (!cancelled) setJiraBoards([]); }) + .finally(() => { if (!cancelled) setLoadingBoards(false); }); + return () => { cancelled = true; }; + }, [formData.jiraInstanceId, formData.jiraProjectKey]); + + // Show the selected board's active sprint as confirmation it's the right board. + useEffect(() => { + if (!formData.jiraInstanceId || !formData.jiraBoardId) { + setBoardSprints([]); + return; + } + let cancelled = false; + api.getJiraBoardSprints(formData.jiraInstanceId, formData.jiraBoardId, { silent: true }) + .then(sprints => { if (!cancelled) setBoardSprints(sprints || []); }) + .catch(() => { if (!cancelled) setBoardSprints([]); }); + return () => { cancelled = true; }; + }, [formData.jiraInstanceId, formData.jiraBoardId]); + + // Validate the configured epic key still resolves as an Epic on this instance. + useEffect(() => { + const key = formData.jiraEpicKey.trim(); + if (!formData.jiraInstanceId || !EPIC_KEY_RE.test(key)) { + setEpicValidation({ state: 'idle' }); + return; + } + let cancelled = false; + setEpicValidation({ state: 'checking' }); + const t = setTimeout(() => { + api.getJiraIssue(formData.jiraInstanceId, key, { silent: true }) + .then(issue => { + if (cancelled) return; + const isEpic = (issue.issueType || '').toLowerCase() === 'epic'; + setEpicValidation({ state: isEpic ? 'ok' : 'wrongtype', issue }); + }) + .catch(() => { if (!cancelled) setEpicValidation({ state: 'stale' }); }); + }, 400); + return () => { cancelled = true; clearTimeout(t); }; + }, [formData.jiraInstanceId, formData.jiraEpicKey]); + + // When the epic field holds free text (not a key), search epics by name to pick one. + useEffect(() => { + const q = formData.jiraEpicKey.trim(); + if (!formData.jiraInstanceId || !formData.jiraProjectKey || q.length < 2 || EPIC_KEY_RE.test(q)) { + setEpicResults([]); + return; + } + let cancelled = false; + const t = setTimeout(() => { + api.searchJiraEpics(formData.jiraInstanceId, formData.jiraProjectKey, q, { silent: true }) + .then(results => { if (!cancelled) setEpicResults(results || []); }) + .catch(() => { if (!cancelled) setEpicResults([]); }); + }, 300); + return () => { cancelled = true; clearTimeout(t); }; + }, [formData.jiraInstanceId, formData.jiraProjectKey, formData.jiraEpicKey]); + // Project-key combobox options: filter by the search box, sort by key, cap at // 100. Derived once per render so the predicate isn't duplicated between the // option list and the "no matching projects" empty-state check below. @@ -161,6 +237,22 @@ export default function EditAppDrawer({ app, onClose, onSave }) { .sort((a, b) => a.key.localeCompare(b.key)) .slice(0, 100); + // A saved boardId that isn't among the project's detected boards is stale + // (e.g. carried over from a Server instance after a Cloud migration). + const boardIsStale = !!formData.jiraBoardId && jiraBoards.length > 0 + && !jiraBoards.some(b => String(b.id) === String(formData.jiraBoardId)); + const activeSprint = boardSprints[0] || null; + + // Changing the instance or project invalidates the selected board, so clear it + // in one place — every discrete instance/project change goes through these so no + // call site forgets the reset. Deliberately interaction-driven, NOT a projectKey + // effect: an effect would fire on mount with the saved projectKey and wipe the + // saved boardId before the boards fetch resolves, defeating stale-board detection. + const changeInstance = (jiraInstanceId) => + setFormData(prev => ({ ...prev, jiraInstanceId, jiraProjectKey: '', jiraBoardId: '' })); + const selectProject = (jiraProjectKey) => + setFormData(prev => ({ ...prev, jiraProjectKey, jiraBoardId: '' })); + const handleSubmit = async (e) => { e.preventDefault(); setError(null); @@ -217,7 +309,7 @@ export default function EditAppDrawer({ app, onClose, onSave }) { issueType: formData.jiraIssueType || 'Task', labels: formData.jiraLabels ? formData.jiraLabels.split(',').map(s => s.trim()).filter(Boolean) : [], assignee: formData.jiraAssignee || undefined, - epicKey: formData.jiraEpicKey || undefined, + epicKey: formData.jiraEpicKey.trim() || undefined, createPR: formData.jiraCreatePR } : { enabled: false }, datadog: formData.datadogEnabled ? { @@ -536,7 +628,7 @@ export default function EditAppDrawer({ app, onClose, onSave }) { setFormData({ ...formData, jiraBoardId: e.target.value })} - className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden" - placeholder="e.g. 11810 (from JIRA board URL rapidView param)" - /> + + {!formData.jiraProjectKey ? ( +
Select a project to detect its boards.
+ ) : loadingBoards ? ( +
Detecting boards…
+ ) : jiraBoards.length > 0 ? ( + <> + + {boardIsStale ? ( +

⚠ Saved board {formData.jiraBoardId} isn't among this project's boards — pick a current one.

+ ) : formData.jiraBoardId && ( +

+ {activeSprint ? `Active sprint: ${activeSprint.name}` : 'No active sprint on this board.'} +

+ )} + + ) : ( + <> + setFormData({ ...formData, jiraBoardId: e.target.value })} + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden" + placeholder="e.g. 1294 (from JIRA board URL rapidView param)" + /> +

Couldn't auto-detect boards — enter the id manually (board URL rapidView param).

+ + )}
@@ -670,16 +795,49 @@ export default function EditAppDrawer({ app, onClose, onSave }) { />
-
+
setFormData({ ...formData, jiraEpicKey: e.target.value })} + onFocus={() => setEpicDropdownOpen(true)} + onBlur={() => setTimeout(() => setEpicDropdownOpen(false), 150)} className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden" - placeholder="e.g. CONTECH-100" + placeholder="e.g. CONTECH-100, or type a name to search" /> + {epicDropdownOpen && epicResults.length > 0 && ( +
+ {epicResults.map(ep => ( + + ))} +
+ )} + {epicValidation.state !== 'idle' && ( +

+ {epicValidation.state === 'checking' && 'Checking epic…'} + {epicValidation.state === 'ok' && `✓ ${epicValidation.issue.key} · ${epicValidation.issue.summary}`} + {epicValidation.state === 'wrongtype' && `⚠ ${epicValidation.issue.key} is a ${epicValidation.issue.issueType}, not an Epic`} + {epicValidation.state === 'stale' && `⚠ ${formData.jiraEpicKey} doesn't resolve on this instance`} +

+ )}