Skip to content

Commit 3d31686

Browse files
authored
Merge pull request #2239 from atomantic/jira-workspace-detector
jira: auto-detect boards & epics when configuring an app's JIRA integration
2 parents a21ed61 + 0f6e797 commit 3d31686

5 files changed

Lines changed: 277 additions & 16 deletions

File tree

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
## Integrations
108108

109109
- **JIRA instances now support Jira Cloud (`*.atlassian.net`), not just Server / Data Center.** The JIRA client authenticated every instance with `Authorization: Bearer <token>` — 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`)
110+
- **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`)
110111

111112
## Internal
112113

client/src/components/apps/EditAppDrawer.jsx

Lines changed: 173 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ const PORT_FIELDS = [
4040
['tlsPort', 'TLS Port']
4141
];
4242

43+
// A JIRA issue key like CONTECH-1553 — used to tell "user typed a key to validate"
44+
// from "user is searching an epic by name" in the epic field.
45+
const EPIC_KEY_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
46+
4347
export default function EditAppDrawer({ app, onClose, onSave }) {
4448
const [activeTab, setActiveTab] = useDrawerTab('appTab', 'general', TAB_IDS);
4549
const [formData, setFormData] = useState({
@@ -112,6 +116,12 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
112116
const [loadingProjects, setLoadingProjects] = useState(false);
113117
const [projectSearch, setProjectSearch] = useState('');
114118
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
119+
const [jiraBoards, setJiraBoards] = useState([]);
120+
const [loadingBoards, setLoadingBoards] = useState(false);
121+
const [boardSprints, setBoardSprints] = useState([]);
122+
const [epicResults, setEpicResults] = useState([]);
123+
const [epicDropdownOpen, setEpicDropdownOpen] = useState(false);
124+
const [epicValidation, setEpicValidation] = useState({ state: 'idle' });
115125

116126
useEffect(() => {
117127
const toInstances = (data) => data?.instances ? Object.values(data.instances) : [];
@@ -149,6 +159,72 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
149159
}
150160
}, [formData.jiraInstanceId, jiraInstances, formData.jiraAssignee]);
151161

162+
// Detect the project's agile boards so the boardId is picked from live data
163+
// instead of hand-typed (which is how a boardId goes stale across a migration).
164+
useEffect(() => {
165+
if (!formData.jiraInstanceId || !formData.jiraProjectKey) {
166+
setJiraBoards([]);
167+
return;
168+
}
169+
let cancelled = false;
170+
setLoadingBoards(true);
171+
api.getJiraBoards(formData.jiraInstanceId, formData.jiraProjectKey, { silent: true })
172+
.then(boards => { if (!cancelled) setJiraBoards(boards || []); })
173+
.catch(() => { if (!cancelled) setJiraBoards([]); })
174+
.finally(() => { if (!cancelled) setLoadingBoards(false); });
175+
return () => { cancelled = true; };
176+
}, [formData.jiraInstanceId, formData.jiraProjectKey]);
177+
178+
// Show the selected board's active sprint as confirmation it's the right board.
179+
useEffect(() => {
180+
if (!formData.jiraInstanceId || !formData.jiraBoardId) {
181+
setBoardSprints([]);
182+
return;
183+
}
184+
let cancelled = false;
185+
api.getJiraBoardSprints(formData.jiraInstanceId, formData.jiraBoardId, { silent: true })
186+
.then(sprints => { if (!cancelled) setBoardSprints(sprints || []); })
187+
.catch(() => { if (!cancelled) setBoardSprints([]); });
188+
return () => { cancelled = true; };
189+
}, [formData.jiraInstanceId, formData.jiraBoardId]);
190+
191+
// Validate the configured epic key still resolves as an Epic on this instance.
192+
useEffect(() => {
193+
const key = formData.jiraEpicKey.trim();
194+
if (!formData.jiraInstanceId || !EPIC_KEY_RE.test(key)) {
195+
setEpicValidation({ state: 'idle' });
196+
return;
197+
}
198+
let cancelled = false;
199+
setEpicValidation({ state: 'checking' });
200+
const t = setTimeout(() => {
201+
api.getJiraIssue(formData.jiraInstanceId, key, { silent: true })
202+
.then(issue => {
203+
if (cancelled) return;
204+
const isEpic = (issue.issueType || '').toLowerCase() === 'epic';
205+
setEpicValidation({ state: isEpic ? 'ok' : 'wrongtype', issue });
206+
})
207+
.catch(() => { if (!cancelled) setEpicValidation({ state: 'stale' }); });
208+
}, 400);
209+
return () => { cancelled = true; clearTimeout(t); };
210+
}, [formData.jiraInstanceId, formData.jiraEpicKey]);
211+
212+
// When the epic field holds free text (not a key), search epics by name to pick one.
213+
useEffect(() => {
214+
const q = formData.jiraEpicKey.trim();
215+
if (!formData.jiraInstanceId || !formData.jiraProjectKey || q.length < 2 || EPIC_KEY_RE.test(q)) {
216+
setEpicResults([]);
217+
return;
218+
}
219+
let cancelled = false;
220+
const t = setTimeout(() => {
221+
api.searchJiraEpics(formData.jiraInstanceId, formData.jiraProjectKey, q, { silent: true })
222+
.then(results => { if (!cancelled) setEpicResults(results || []); })
223+
.catch(() => { if (!cancelled) setEpicResults([]); });
224+
}, 300);
225+
return () => { cancelled = true; clearTimeout(t); };
226+
}, [formData.jiraInstanceId, formData.jiraProjectKey, formData.jiraEpicKey]);
227+
152228
// Project-key combobox options: filter by the search box, sort by key, cap at
153229
// 100. Derived once per render so the predicate isn't duplicated between the
154230
// option list and the "no matching projects" empty-state check below.
@@ -161,6 +237,22 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
161237
.sort((a, b) => a.key.localeCompare(b.key))
162238
.slice(0, 100);
163239

240+
// A saved boardId that isn't among the project's detected boards is stale
241+
// (e.g. carried over from a Server instance after a Cloud migration).
242+
const boardIsStale = !!formData.jiraBoardId && jiraBoards.length > 0
243+
&& !jiraBoards.some(b => String(b.id) === String(formData.jiraBoardId));
244+
const activeSprint = boardSprints[0] || null;
245+
246+
// Changing the instance or project invalidates the selected board, so clear it
247+
// in one place — every discrete instance/project change goes through these so no
248+
// call site forgets the reset. Deliberately interaction-driven, NOT a projectKey
249+
// effect: an effect would fire on mount with the saved projectKey and wipe the
250+
// saved boardId before the boards fetch resolves, defeating stale-board detection.
251+
const changeInstance = (jiraInstanceId) =>
252+
setFormData(prev => ({ ...prev, jiraInstanceId, jiraProjectKey: '', jiraBoardId: '' }));
253+
const selectProject = (jiraProjectKey) =>
254+
setFormData(prev => ({ ...prev, jiraProjectKey, jiraBoardId: '' }));
255+
164256
const handleSubmit = async (e) => {
165257
e.preventDefault();
166258
setError(null);
@@ -217,7 +309,7 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
217309
issueType: formData.jiraIssueType || 'Task',
218310
labels: formData.jiraLabels ? formData.jiraLabels.split(',').map(s => s.trim()).filter(Boolean) : [],
219311
assignee: formData.jiraAssignee || undefined,
220-
epicKey: formData.jiraEpicKey || undefined,
312+
epicKey: formData.jiraEpicKey.trim() || undefined,
221313
createPR: formData.jiraCreatePR
222314
} : { enabled: false },
223315
datadog: formData.datadogEnabled ? {
@@ -536,7 +628,7 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
536628
<select
537629
id="edit-app-jira-instance"
538630
value={formData.jiraInstanceId}
539-
onChange={e => setFormData({ ...formData, jiraInstanceId: e.target.value, jiraProjectKey: '' })}
631+
onChange={e => changeInstance(e.target.value)}
540632
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"
541633
>
542634
<option value="">Select instance...</option>
@@ -575,7 +667,7 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
575667
{formData.jiraProjectKey && !projectDropdownOpen && (
576668
<button
577669
type="button"
578-
onClick={() => setFormData({ ...formData, jiraProjectKey: '' })}
670+
onClick={() => selectProject('')}
579671
className="absolute right-2 top-8 text-gray-500 hover:text-white text-sm"
580672
>
581673
x
@@ -590,7 +682,7 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
590682
type="button"
591683
onMouseDown={e => {
592684
e.preventDefault();
593-
setFormData({ ...formData, jiraProjectKey: proj.key });
685+
selectProject(proj.key);
594686
setProjectDropdownOpen(false);
595687
setProjectSearch('');
596688
}}
@@ -622,15 +714,48 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
622714
</div>
623715

624716
<div>
625-
<label htmlFor="edit-app-jira-board" className="block text-sm text-gray-400 mb-1">Board ID</label>
626-
<input
627-
id="edit-app-jira-board"
628-
type="text"
629-
value={formData.jiraBoardId}
630-
onChange={e => setFormData({ ...formData, jiraBoardId: e.target.value })}
631-
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"
632-
placeholder="e.g. 11810 (from JIRA board URL rapidView param)"
633-
/>
717+
<label htmlFor="edit-app-jira-board" className="block text-sm text-gray-400 mb-1">Board</label>
718+
{!formData.jiraProjectKey ? (
719+
<div className="text-xs text-gray-500">Select a project to detect its boards.</div>
720+
) : loadingBoards ? (
721+
<div className="text-xs text-gray-500">Detecting boards…</div>
722+
) : jiraBoards.length > 0 ? (
723+
<>
724+
<select
725+
id="edit-app-jira-board"
726+
value={formData.jiraBoardId}
727+
onChange={e => setFormData({ ...formData, jiraBoardId: e.target.value })}
728+
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"
729+
>
730+
<option value="">Select board...</option>
731+
{jiraBoards.map(b => (
732+
<option key={b.id} value={String(b.id)}>{b.id}{b.name} ({b.type})</option>
733+
))}
734+
{boardIsStale && (
735+
<option value={formData.jiraBoardId}>{formData.jiraBoardId} — (not in this project)</option>
736+
)}
737+
</select>
738+
{boardIsStale ? (
739+
<p className="text-xs text-port-warning mt-1">⚠ Saved board {formData.jiraBoardId} isn&apos;t among this project&apos;s boards — pick a current one.</p>
740+
) : formData.jiraBoardId && (
741+
<p className="text-xs text-gray-500 mt-1">
742+
{activeSprint ? `Active sprint: ${activeSprint.name}` : 'No active sprint on this board.'}
743+
</p>
744+
)}
745+
</>
746+
) : (
747+
<>
748+
<input
749+
id="edit-app-jira-board"
750+
type="text"
751+
value={formData.jiraBoardId}
752+
onChange={e => setFormData({ ...formData, jiraBoardId: e.target.value })}
753+
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"
754+
placeholder="e.g. 1294 (from JIRA board URL rapidView param)"
755+
/>
756+
<p className="text-xs text-gray-500 mt-1">Couldn&apos;t auto-detect boards — enter the id manually (board URL <span className="font-mono">rapidView</span> param).</p>
757+
</>
758+
)}
634759
</div>
635760

636761
<div className="grid grid-cols-2 gap-3">
@@ -670,16 +795,49 @@ export default function EditAppDrawer({ app, onClose, onSave }) {
670795
/>
671796
</div>
672797

673-
<div>
798+
<div className="relative">
674799
<label htmlFor="edit-app-jira-epic" className="block text-sm text-gray-400 mb-1">Epic Key</label>
675800
<input
676801
id="edit-app-jira-epic"
677802
type="text"
678803
value={formData.jiraEpicKey}
679804
onChange={e => setFormData({ ...formData, jiraEpicKey: e.target.value })}
805+
onFocus={() => setEpicDropdownOpen(true)}
806+
onBlur={() => setTimeout(() => setEpicDropdownOpen(false), 150)}
680807
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"
681-
placeholder="e.g. CONTECH-100"
808+
placeholder="e.g. CONTECH-100, or type a name to search"
682809
/>
810+
{epicDropdownOpen && epicResults.length > 0 && (
811+
<div className="absolute z-50 w-full mt-1 bg-port-bg border border-port-border rounded-lg max-h-48 overflow-auto shadow-lg">
812+
{epicResults.map(ep => (
813+
<button
814+
key={ep.key}
815+
type="button"
816+
onMouseDown={e => {
817+
e.preventDefault();
818+
setFormData({ ...formData, jiraEpicKey: ep.key });
819+
setEpicDropdownOpen(false);
820+
}}
821+
className="w-full text-left px-3 py-2 text-sm hover:bg-port-accent/20 text-white"
822+
>
823+
<span className="font-mono">{ep.key}</span>
824+
<span className="text-gray-400 ml-2">{ep.summary}</span>
825+
</button>
826+
))}
827+
</div>
828+
)}
829+
{epicValidation.state !== 'idle' && (
830+
<p className={`text-xs mt-1 ${
831+
epicValidation.state === 'ok' ? 'text-port-success'
832+
: epicValidation.state === 'checking' ? 'text-gray-500'
833+
: 'text-port-warning'
834+
}`}>
835+
{epicValidation.state === 'checking' && 'Checking epic…'}
836+
{epicValidation.state === 'ok' && `✓ ${epicValidation.issue.key} · ${epicValidation.issue.summary}`}
837+
{epicValidation.state === 'wrongtype' && `⚠ ${epicValidation.issue.key} is a ${epicValidation.issue.issueType}, not an Epic`}
838+
{epicValidation.state === 'stale' && `⚠ ${formData.jiraEpicKey} doesn't resolve on this instance`}
839+
</p>
840+
)}
683841
</div>
684842

685843
<label className="flex items-center gap-2 cursor-pointer">

client/src/services/apiSystem.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,15 @@ export const searchDatadogErrors = (instanceId, serviceName, environment, fromTi
250250

251251
// JIRA
252252
export const getJiraInstances = () => request('/jira/instances');
253-
export const getJiraProjects = (instanceId) => request(`/jira/instances/${instanceId}/projects`);
253+
export const getJiraProjects = (instanceId, options) => request(`/jira/instances/${instanceId}/projects`, options);
254+
export const getJiraBoards = (instanceId, projectKey, options) =>
255+
request(`/jira/instances/${instanceId}/projects/${encodeURIComponent(projectKey)}/boards`, options);
256+
export const getJiraBoardSprints = (instanceId, boardId, options) =>
257+
request(`/jira/instances/${instanceId}/boards/${encodeURIComponent(boardId)}/sprints`, options);
258+
export const searchJiraEpics = (instanceId, projectKey, query, options) =>
259+
request(`/jira/instances/${instanceId}/projects/${encodeURIComponent(projectKey)}/epics?q=${encodeURIComponent(query || '')}`, options);
260+
export const getJiraIssue = (instanceId, issueKey, options) =>
261+
request(`/jira/instances/${instanceId}/issues/${encodeURIComponent(issueKey)}`, options);
254262
export const getMySprintTickets = (instanceId, projectKey, options) => request(`/jira/instances/${instanceId}/my-sprint-tickets/${projectKey}`, options);
255263
export const getJiraBoardColumns = (instanceId, projectKey, boardId, options) =>
256264
request(`/jira/instances/${instanceId}/board-columns/${projectKey}${boardId ? `?boardId=${encodeURIComponent(boardId)}` : ''}`, options);

server/routes/jira.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,30 @@ router.get('/instances/:instanceId/boards/:boardId/sprints', asyncHandler(async
253253
res.json(sprints);
254254
}));
255255

256+
/**
257+
* GET /api/jira/instances/:instanceId/projects/:projectKey/boards
258+
* List agile boards (Scrum + Kanban) for a project — powers the app-config board picker.
259+
*/
260+
router.get('/instances/:instanceId/projects/:projectKey/boards', asyncHandler(async (req, res) => {
261+
const boards = await jiraService.getBoards(
262+
req.params.instanceId,
263+
req.params.projectKey
264+
);
265+
res.json(boards);
266+
}));
267+
268+
/**
269+
* GET /api/jira/instances/:instanceId/issues/:issueKey
270+
* Resolve a single issue by key — used to validate a configured epicKey.
271+
*/
272+
router.get('/instances/:instanceId/issues/:issueKey', asyncHandler(async (req, res) => {
273+
const issue = await jiraService.getIssue(
274+
req.params.instanceId,
275+
req.params.issueKey
276+
);
277+
res.json(issue);
278+
}));
279+
256280
/**
257281
* GET /api/jira/instances/:instanceId/projects/:projectKey/epics?q=search
258282
* Search for epics by name in a project

0 commit comments

Comments
 (0)