Skip to content

Commit 139484c

Browse files
committed
feat(jira): add optional roadmap-based labeling for synced issues
Added `labelRoadmapSource` config option to automatically label Jira issues with sanitized roadmap titles. Implemented `sanitizeRoadmapLabel`, `addLabelToIssue`, and `applyRoadmapLabel` helpers to create lowercase-hyphenated labels from roadmap names. Updated `syncMilestone`, `syncInitiative`, `syncWorkflow`, and `syncPulses` to accept optional `roadmapTitle` parameter and apply labels after issue creation/update. Modified JiraSyncQueue to fetch
1 parent ed3533a commit 139484c

4 files changed

Lines changed: 114 additions & 11 deletions

File tree

src/backend/services/jira.ts

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -829,11 +829,51 @@ async function findIssueTypeId(
829829
return match?.id ?? null;
830830
}
831831

832+
// =============================================================================
833+
// Roadmap Labeling
834+
// =============================================================================
835+
836+
function sanitizeRoadmapLabel(title: string): string {
837+
return title
838+
.toLowerCase()
839+
.replace(/[^a-z0-9]+/g, "-")
840+
.replace(/^-|-$/g, "");
841+
}
842+
843+
async function addLabelToIssue(
844+
auth: JiraAuth,
845+
issueKey: string,
846+
label: string,
847+
): Promise<boolean> {
848+
const result = await jiraFetch(auth, "PUT", `/issue/${issueKey}`, {
849+
update: { labels: [{ add: label }] },
850+
});
851+
return result.ok;
852+
}
853+
854+
async function applyRoadmapLabel(
855+
auth: JiraAuth,
856+
config: JiraConfig,
857+
issueKey: string,
858+
roadmapTitle: string | undefined,
859+
): Promise<void> {
860+
if (!config.labelRoadmapSource || !roadmapTitle) return;
861+
const label = sanitizeRoadmapLabel(roadmapTitle);
862+
if (!label) return;
863+
const ok = await addLabelToIssue(auth, issueKey, label);
864+
if (!ok) {
865+
log.jira.warn(`Failed to apply roadmap label "${label}" to ${issueKey}`);
866+
}
867+
}
868+
832869
/**
833870
* Sync a Milestone → Jira Epic
834871
* Creates or updates the Epic in Jira.
835872
*/
836-
export async function syncMilestone(milestone: Milestone): Promise<void> {
873+
export async function syncMilestone(
874+
milestone: Milestone,
875+
roadmapTitle?: string,
876+
): Promise<void> {
837877
const ctx = await resolveConfigAndAuth();
838878
if (!ctx?.config.syncRoadmaps) return;
839879

@@ -842,9 +882,10 @@ export async function syncMilestone(milestone: Milestone): Promise<void> {
842882
await updateMilestoneSyncStatus(milestone.id, "pending");
843883

844884
try {
845-
if (milestone.jiraEpicKey) {
885+
const existingEpicKey = milestone.jiraEpicKey;
886+
if (existingEpicKey) {
846887
// Update existing
847-
const success = await updateIssue(auth, milestone.jiraEpicKey, {
888+
const success = await updateIssue(auth, existingEpicKey, {
848889
summary: milestone.title,
849890
...(milestone.description
850891
? {
@@ -855,8 +896,9 @@ export async function syncMilestone(milestone: Milestone): Promise<void> {
855896

856897
if (success) {
857898
await updateMilestoneSyncStatus(milestone.id, "synced");
899+
await applyRoadmapLabel(auth, config, existingEpicKey, roadmapTitle);
858900
log.jira.info(
859-
`Updated Epic ${milestone.jiraEpicKey} for milestone ${milestone.id}`,
901+
`Updated Epic ${existingEpicKey} for milestone ${milestone.id}`,
860902
);
861903
} else {
862904
await updateMilestoneSyncStatus(
@@ -900,6 +942,7 @@ export async function syncMilestone(milestone: Milestone): Promise<void> {
900942
issue.key,
901943
issue.id,
902944
);
945+
await applyRoadmapLabel(auth, config, issue.key, roadmapTitle);
903946
log.jira.info(
904947
`Created Epic ${issue.key} for milestone ${milestone.id}`,
905948
);
@@ -933,6 +976,7 @@ export async function syncMilestone(milestone: Milestone): Promise<void> {
933976
export async function syncInitiative(
934977
initiative: Initiative,
935978
parentEpicKey?: string,
979+
roadmapTitle?: string,
936980
): Promise<void> {
937981
const ctx = await resolveConfigAndAuth();
938982
if (!ctx?.config.syncRoadmaps) return;
@@ -945,7 +989,8 @@ export async function syncInitiative(
945989
config.initiativePriorityMapping[initiative.priority] ?? undefined;
946990

947991
try {
948-
if (initiative.jiraIssueKey) {
992+
const existingIssueKey = initiative.jiraIssueKey;
993+
if (existingIssueKey) {
949994
// Update existing
950995
const fields: Record<string, unknown> = {
951996
summary: initiative.title,
@@ -957,12 +1002,13 @@ export async function syncInitiative(
9571002
fields.priority = { id: priorityId };
9581003
}
9591004

960-
const success = await updateIssue(auth, initiative.jiraIssueKey, fields);
1005+
const success = await updateIssue(auth, existingIssueKey, fields);
9611006

9621007
if (success) {
9631008
await updateInitiativeSyncStatus(initiative.id, "synced");
1009+
await applyRoadmapLabel(auth, config, existingIssueKey, roadmapTitle);
9641010
log.jira.info(
965-
`Updated Story ${initiative.jiraIssueKey} for initiative ${initiative.id}`,
1011+
`Updated Story ${existingIssueKey} for initiative ${initiative.id}`,
9661012
);
9671013
} else {
9681014
await updateInitiativeSyncStatus(
@@ -1008,6 +1054,7 @@ export async function syncInitiative(
10081054
issue.key,
10091055
issue.id,
10101056
);
1057+
await applyRoadmapLabel(auth, config, issue.key, roadmapTitle);
10111058
log.jira.info(
10121059
`Created Story ${issue.key} for initiative ${initiative.id}`,
10131060
);
@@ -1043,6 +1090,7 @@ export async function syncInitiative(
10431090
export async function syncWorkflow(
10441091
workflow: Workflow,
10451092
initiativeJira?: { key: string; id?: string },
1093+
roadmapTitle?: string,
10461094
): Promise<void> {
10471095
const ctx = await resolveConfigAndAuth();
10481096
if (!ctx?.config.syncWorkflows) return;
@@ -1072,6 +1120,8 @@ export async function syncWorkflow(
10721120
} else {
10731121
await updateWorkflowSyncStatus(workflow.id, "synced");
10741122
}
1123+
// Label the adopted issue (idempotent if initiative sync already labelled it)
1124+
await applyRoadmapLabel(auth, config, initiativeJira.key, roadmapTitle);
10751125
} else if (workflow.jiraIssueKey) {
10761126
// Standalone workflow — update existing Story
10771127
const fields: Record<string, unknown> = {
@@ -1343,6 +1393,7 @@ export async function syncArtifactComment(
13431393
export async function syncPulses(
13441394
plan: Plan,
13451395
parentTaskKey: string,
1396+
roadmapTitle?: string,
13461397
): Promise<void> {
13471398
const ctx = await resolveConfigAndAuth();
13481399
if (!ctx?.config.syncWorkflows) return;
@@ -1401,6 +1452,8 @@ export async function syncPulses(
14011452
await repos.pulses.updateJiraIssueId(existingPulse.id, issue.key);
14021453
}
14031454

1455+
await applyRoadmapLabel(auth, config, issue.key, roadmapTitle);
1456+
14041457
// Assign sub-task to the authenticated user
14051458
try {
14061459
const accountId = await getCurrentUserAccountId(auth);

src/backend/services/jiraSyncQueue.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,10 @@ class JiraSyncQueue {
162162
const initiativeJira = initiative?.jiraIssueKey
163163
? { key: initiative.jiraIssueKey, id: initiative.jiraIssueId }
164164
: undefined;
165-
await syncWorkflow(workflow, initiativeJira);
165+
const roadmap = initiative?.roadmapId
166+
? await repos.roadmaps.getRoadmap(initiative.roadmapId)
167+
: null;
168+
await syncWorkflow(workflow, initiativeJira, roadmap?.title);
166169

167170
return;
168171
}
@@ -226,7 +229,8 @@ class JiraSyncQueue {
226229
);
227230
return;
228231
}
229-
await syncMilestone(milestone);
232+
const roadmap = await repos.roadmaps.getRoadmap(milestone.roadmapId);
233+
await syncMilestone(milestone, roadmap?.title);
230234
return;
231235
}
232236

@@ -243,7 +247,12 @@ class JiraSyncQueue {
243247
const milestone = await repos.roadmaps.getMilestone(
244248
initiative.milestoneId,
245249
);
246-
await syncInitiative(initiative, milestone?.jiraEpicKey);
250+
const roadmap = await repos.roadmaps.getRoadmap(initiative.roadmapId);
251+
await syncInitiative(
252+
initiative,
253+
milestone?.jiraEpicKey,
254+
roadmap?.title,
255+
);
247256
return;
248257
}
249258

@@ -263,7 +272,13 @@ class JiraSyncQueue {
263272
return;
264273
}
265274

266-
await syncPulses(plan, workflow.jiraIssueKey);
275+
const initiative = await repos.roadmaps.findInitiativeByWorkflowId(
276+
job.workflowId,
277+
);
278+
const roadmap = initiative?.roadmapId
279+
? await repos.roadmaps.getRoadmap(initiative.roadmapId)
280+
: null;
281+
await syncPulses(plan, workflow.jiraIssueKey, roadmap?.title);
267282
return;
268283
}
269284

src/features/settings/components/JiraSection.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "lucide-react";
99
import { useCallback, useEffect, useState } from "react";
1010
import { Button } from "@/components/ui/button";
11+
import { Checkbox } from "@/components/ui/checkbox";
1112
import { Input } from "@/components/ui/input";
1213
import {
1314
Select,
@@ -554,6 +555,37 @@ export function JiraSection() {
554555
</div>
555556
)}
556557

558+
{/* Roadmap label toggle */}
559+
{credentialsConfigured && config && !isEditing && (
560+
<div className="mt-3 flex items-start gap-2">
561+
<Checkbox
562+
id="jira-label-roadmap"
563+
checked={config.labelRoadmapSource}
564+
onCheckedChange={async (checked) => {
565+
const updated: JiraConfig = {
566+
...config,
567+
labelRoadmapSource: checked === true,
568+
};
569+
setConfig(updated);
570+
await saveJiraConfig(updated);
571+
}}
572+
className="mt-0.5"
573+
/>
574+
<label
575+
htmlFor="jira-label-roadmap"
576+
className="text-xs text-zinc-400 cursor-pointer select-none"
577+
>
578+
<span className="text-zinc-300">
579+
Label issues with roadmap name
580+
</span>
581+
<br />
582+
Adds a sanitized label (e.g.{" "}
583+
<code className="text-zinc-500">observability-and-monitoring</code>)
584+
to every Jira issue created from a roadmap.
585+
</label>
586+
</div>
587+
)}
588+
557589
{/* Status & Priority Mapping Panel */}
558590
{credentialsConfigured && config && !isEditing && (
559591
<div className="mt-3">

src/shared/schemas/jira.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ export const JiraConfigSchema = z.object({
4747
syncWorkflows: z.boolean().default(true),
4848
syncArtifacts: z.boolean().default(true),
4949

50+
// Labeling
51+
labelRoadmapSource: z.boolean().default(false),
52+
5053
// Status mapping per Autarch object type
5154
statusMapping: z
5255
.object({

0 commit comments

Comments
 (0)