Skip to content

Commit bed088d

Browse files
authored
Merge pull request #21 from duckduckgo/mariusz/add-feature-flag-check
Add check for proper feature flag task comment for all PR's
2 parents b6f233c + 8ce1323 commit bed088d

File tree

2 files changed

+377
-0
lines changed

2 files changed

+377
-0
lines changed

org/allPRs.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,96 @@ export const releaseAndHotfixBranchBSKChangeWarning = async () => {
287287
warn(`Please check whether the BSK changes on this branch need to be merged to the other platform's release/hotfix branch`);
288288
}
289289

290+
export const featureFlagAsanaLink = async () => {
291+
const featureFlagFiles = [
292+
"iOS/Core/FeatureFlag.swift",
293+
"macOS/LocalPackages/FeatureFlags/Sources/FeatureFlags/FeatureFlag.swift"
294+
];
295+
296+
const changedFiles = [
297+
...danger.git.modified_files,
298+
...danger.git.created_files
299+
].filter(file => featureFlagFiles.includes(file));
300+
301+
if (changedFiles.length === 0) return;
302+
303+
const asanaTaskUrlRegex = /^\/\/\/\s*https:\/\/app\.asana\.com\/1\/137249556945\/project\/1211834678943996\/task\/\d+(\?\S*)?\s*$/;
304+
const casesWithInvalidLinks: { file: string; caseName: string }[] = [];
305+
306+
for (const file of changedFiles) {
307+
const structuredDiff = await danger.git.structuredDiffForFile(file);
308+
if (!structuredDiff) continue;
309+
310+
for (const chunk of structuredDiff.chunks) {
311+
let insideFeatureFlagEnum = false;
312+
let braceDepth = 0;
313+
314+
// Check if the hunk header context mentions FeatureFlag enum
315+
if (/enum\s+FeatureFlag\b/.test(chunk.content)) {
316+
insideFeatureFlagEnum = true;
317+
braceDepth = 1;
318+
}
319+
320+
const changes = chunk.changes;
321+
322+
for (let i = 0; i < changes.length; i++) {
323+
const change = changes[i];
324+
325+
// Skip removed lines – they don't exist in the new file
326+
if (change.type === "del") continue;
327+
328+
const content = change.content.length > 0 ? change.content.substring(1) : "";
329+
330+
// Track enum FeatureFlag declaration
331+
if (/\benum\s+FeatureFlag\b/.test(content)) {
332+
insideFeatureFlagEnum = true;
333+
braceDepth = 0;
334+
}
335+
336+
// Track brace depth when inside FeatureFlag enum
337+
if (insideFeatureFlagEnum) {
338+
braceDepth += (content.match(/{/g) || []).length;
339+
braceDepth -= (content.match(/}/g) || []).length;
340+
341+
if (braceDepth <= 0) {
342+
insideFeatureFlagEnum = false;
343+
continue;
344+
}
345+
}
346+
347+
if (!insideFeatureFlagEnum) continue;
348+
349+
// Only check added lines for new case declarations
350+
if (change.type !== "add") continue;
351+
const caseMatch = content.match(/^\s*case\s+(\w+)/);
352+
if (!caseMatch) continue;
353+
354+
// Found an added case line – walk upward through added comment lines looking for the Asana URL
355+
let foundAsanaLink = false;
356+
for (let j = i - 1; j >= 0; j--) {
357+
const prevChange = changes[j];
358+
if (prevChange.type !== "add") break;
359+
const prevContent = prevChange.content.substring(1).trim();
360+
if (!prevContent.startsWith("///")) break;
361+
if (asanaTaskUrlRegex.test(prevContent)) {
362+
foundAsanaLink = true;
363+
break;
364+
}
365+
}
366+
367+
if (!foundAsanaLink) {
368+
casesWithInvalidLinks.push({ file, caseName: caseMatch[1] });
369+
}
370+
}
371+
}
372+
}
373+
374+
if (casesWithInvalidLinks.length > 0) {
375+
const caseList = casesWithInvalidLinks.map(c => `- \`${c.caseName}\` in \`${c.file}\``).join("\n");
376+
warn(`New FeatureFlag cases are missing a valid Feature Flag link in the comment:\n${caseList}\n\nAdd a task in the [Feature Flags project](https://app.asana.com/1/137249556945/project/1211834678943996/list/1211838475578067) and use it in the comment.\nExpected format: \`/// https://app.asana.com/1/137249556945/project/1211834678943996/task/<task_id>\``);
377+
}
378+
}
379+
290380
// Default run
291381
export default async () => {
292382
await prSize()
@@ -301,4 +391,5 @@ export default async () => {
301391
await newColors()
302392
await embeddedFilesURLMismatch()
303393
await releaseAndHotfixBranchBSKChangeWarning()
394+
await featureFlagAsanaLink()
304395
}
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
jest.mock("danger", () => jest.fn())
2+
import danger from 'danger'
3+
const dm = danger as any;
4+
5+
import { featureFlagAsanaLink } from '../org/allPRs'
6+
7+
// Helper to build a structuredDiff from a raw unified diff string.
8+
// Parses hunk headers (@@ lines) and change lines (+, -, context).
9+
function buildStructuredDiff(rawDiff: string) {
10+
const lines = rawDiff.split(/\n/);
11+
const chunks: any[] = [];
12+
let currentChunk: any = null;
13+
14+
for (const line of lines) {
15+
if (line.startsWith("@@")) {
16+
currentChunk = { content: line, changes: [] };
17+
chunks.push(currentChunk);
18+
} else if (currentChunk && line.length > 0) {
19+
let type: string;
20+
if (line.startsWith("+")) {
21+
type = "add";
22+
} else if (line.startsWith("-")) {
23+
type = "del";
24+
} else {
25+
type = "normal";
26+
}
27+
currentChunk.changes.push({ type, content: line });
28+
}
29+
}
30+
31+
return { chunks };
32+
}
33+
34+
beforeEach(() => {
35+
dm.rawDiff = ""
36+
dm.warn = jest.fn().mockReturnValue(true);
37+
38+
dm.danger = {
39+
git: {
40+
structuredDiffForFile: async (_filename) => {
41+
if (!dm.rawDiff) return null;
42+
return buildStructuredDiff(dm.rawDiff);
43+
},
44+
modified_files: [
45+
"iOS/Core/FeatureFlag.swift"
46+
],
47+
created_files: []
48+
}
49+
}
50+
})
51+
52+
describe("Feature flag Asana link checks", () => {
53+
it("does not warn when no feature flag files are changed", async () => {
54+
dm.danger.git.modified_files = ["SomeOtherFile.swift"]
55+
56+
await featureFlagAsanaLink()
57+
expect(dm.warn).not.toHaveBeenCalled()
58+
})
59+
60+
it("does not warn when diff has no added case lines", async () => {
61+
dm.rawDiff = `@@ -10,6 +10,8 @@ enum FeatureFlag {
62+
+ var rawValue: String
63+
+ var source: FeatureFlagSource`
64+
65+
await featureFlagAsanaLink()
66+
expect(dm.warn).not.toHaveBeenCalled()
67+
})
68+
69+
it("does not warn when added case has valid Asana link above it", async () => {
70+
dm.rawDiff = `@@ -10,6 +10,8 @@ enum FeatureFlag {
71+
+ /// https://app.asana.com/1/137249556945/project/1211834678943996/task/123456789
72+
+ case myNewFeature`
73+
74+
await featureFlagAsanaLink()
75+
expect(dm.warn).not.toHaveBeenCalled()
76+
})
77+
78+
it("does not warn when Asana link has query parameters", async () => {
79+
dm.rawDiff = `@@ -10,6 +10,8 @@ enum FeatureFlag {
80+
+ /// https://app.asana.com/1/137249556945/project/1211834678943996/task/123456789?focus=true
81+
+ case myNewFeature`
82+
83+
await featureFlagAsanaLink()
84+
expect(dm.warn).not.toHaveBeenCalled()
85+
})
86+
87+
it("does not warn when added case with assignment has valid Asana link above it", async () => {
88+
dm.rawDiff = `@@ -10,6 +10,8 @@ enum FeatureFlag {
89+
+ /// https://app.asana.com/1/137249556945/project/1211834678943996/task/999888777
90+
+ case anotherFeature = "anotherFeature"`
91+
92+
await featureFlagAsanaLink()
93+
expect(dm.warn).not.toHaveBeenCalled()
94+
})
95+
96+
it("warns when added case has no comment above it", async () => {
97+
dm.rawDiff = `@@ -10,6 +10,7 @@ enum FeatureFlag {
98+
+ case myNewFeature`
99+
100+
await featureFlagAsanaLink()
101+
expect(dm.warn).toHaveBeenCalled()
102+
})
103+
104+
it("warns when added case has a non-Asana comment above it", async () => {
105+
dm.rawDiff = `@@ -10,6 +10,8 @@ enum FeatureFlag {
106+
+ /// Some description of the feature
107+
+ case myNewFeature`
108+
109+
await featureFlagAsanaLink()
110+
expect(dm.warn).toHaveBeenCalled()
111+
})
112+
113+
it("warns when added case has an Asana link with wrong project ID", async () => {
114+
dm.rawDiff = `@@ -10,6 +10,8 @@ enum FeatureFlag {
115+
+ /// https://app.asana.com/1/137249556945/project/9999999999999999/task/123456789
116+
+ case myNewFeature`
117+
118+
await featureFlagAsanaLink()
119+
expect(dm.warn).toHaveBeenCalled()
120+
})
121+
122+
it("warns when comment above is a context line (not added)", async () => {
123+
dm.rawDiff = `@@ -10,6 +10,7 @@ enum FeatureFlag {
124+
/// https://app.asana.com/1/137249556945/project/1211834678943996/task/123456789
125+
+ case myNewFeature`
126+
127+
await featureFlagAsanaLink()
128+
expect(dm.warn).toHaveBeenCalled()
129+
})
130+
131+
it("works with macOS feature flag file", async () => {
132+
dm.danger.git.modified_files = [
133+
"macOS/LocalPackages/FeatureFlags/Sources/FeatureFlags/FeatureFlag.swift"
134+
]
135+
dm.rawDiff = `@@ -10,6 +10,8 @@ enum FeatureFlag {
136+
+ /// https://app.asana.com/1/137249556945/project/1211834678943996/task/123456789
137+
+ case macOSFeature`
138+
139+
await featureFlagAsanaLink()
140+
expect(dm.warn).not.toHaveBeenCalled()
141+
})
142+
143+
it("warns for macOS feature flag file with missing link", async () => {
144+
dm.danger.git.modified_files = [
145+
"macOS/LocalPackages/FeatureFlags/Sources/FeatureFlags/FeatureFlag.swift"
146+
]
147+
dm.rawDiff = `@@ -10,6 +10,7 @@ enum FeatureFlag {
148+
+ case macOSFeature`
149+
150+
await featureFlagAsanaLink()
151+
expect(dm.warn).toHaveBeenCalled()
152+
})
153+
154+
it("does not warn when diff is empty", async () => {
155+
dm.rawDiff = ""
156+
157+
await featureFlagAsanaLink()
158+
expect(dm.warn).not.toHaveBeenCalled()
159+
})
160+
161+
it("does not warn for removed case lines", async () => {
162+
dm.rawDiff = `@@ -10,6 +10,5 @@ enum FeatureFlag {
163+
- case removedFeature`
164+
165+
await featureFlagAsanaLink()
166+
expect(dm.warn).not.toHaveBeenCalled()
167+
})
168+
169+
it("does not warn for case added in a different enum", async () => {
170+
dm.rawDiff = `@@ -50,6 +50,7 @@ enum SomeOtherEnum {
171+
+ case otherEnumCase`
172+
173+
await featureFlagAsanaLink()
174+
expect(dm.warn).not.toHaveBeenCalled()
175+
})
176+
177+
it("warns for case in FeatureFlag enum but not in other enum in same diff", async () => {
178+
dm.rawDiff = `@@ -10,6 +10,7 @@ enum SomeOtherEnum {
179+
+ case otherEnumCase
180+
@@ -50,6 +50,7 @@ enum FeatureFlag {
181+
+ case featureFlagCase`
182+
183+
await featureFlagAsanaLink()
184+
expect(dm.warn).toHaveBeenCalled()
185+
})
186+
187+
it("does not warn for case in other enum when FeatureFlag cases are valid", async () => {
188+
dm.rawDiff = `@@ -10,6 +10,7 @@ enum SomeOtherEnum {
189+
+ case otherEnumCase
190+
@@ -50,6 +50,8 @@ enum FeatureFlag {
191+
+ /// https://app.asana.com/1/137249556945/project/1211834678943996/task/123456789
192+
+ case featureFlagCase`
193+
194+
await featureFlagAsanaLink()
195+
expect(dm.warn).not.toHaveBeenCalled()
196+
})
197+
198+
it("tracks FeatureFlag enum declared in the diff itself", async () => {
199+
dm.rawDiff = `@@ -10,6 +10,10 @@
200+
+enum FeatureFlag: String {
201+
+ case myNewFeature
202+
+}`
203+
204+
await featureFlagAsanaLink()
205+
expect(dm.warn).toHaveBeenCalled()
206+
})
207+
208+
it("does not warn when Asana link is above other comment lines", async () => {
209+
dm.rawDiff = `@@ -10,6 +10,10 @@ enum FeatureFlag {
210+
+ /// https://app.asana.com/1/137249556945/project/1211834678943996/task/123456789
211+
+ /// Some description of the feature
212+
+ case myNewFeature`
213+
214+
await featureFlagAsanaLink()
215+
expect(dm.warn).not.toHaveBeenCalled()
216+
})
217+
218+
it("does not warn when Asana link is several comment lines above case", async () => {
219+
dm.rawDiff = `@@ -10,6 +10,11 @@ enum FeatureFlag {
220+
+ /// https://app.asana.com/1/137249556945/project/1211834678943996/task/123456789
221+
+ /// First line of description
222+
+ /// Second line of description
223+
+ case myNewFeature`
224+
225+
await featureFlagAsanaLink()
226+
expect(dm.warn).not.toHaveBeenCalled()
227+
})
228+
229+
it("warns when comment block has no Asana link at all", async () => {
230+
dm.rawDiff = `@@ -10,6 +10,10 @@ enum FeatureFlag {
231+
+ /// First line of description
232+
+ /// Second line of description
233+
+ case myNewFeature`
234+
235+
await featureFlagAsanaLink()
236+
expect(dm.warn).toHaveBeenCalled()
237+
})
238+
239+
it("does not check cases after FeatureFlag enum closing brace", async () => {
240+
dm.rawDiff = `@@ -10,6 +10,12 @@
241+
+enum FeatureFlag: String {
242+
+ /// https://app.asana.com/1/137249556945/project/1211834678943996/task/123456789
243+
+ case validFeature
244+
+}
245+
+enum AnotherEnum {
246+
+ case nolinkNeeded
247+
+}`
248+
249+
await featureFlagAsanaLink()
250+
expect(dm.warn).not.toHaveBeenCalled()
251+
})
252+
253+
it("ignores braces from removed lines when tracking FeatureFlag enum scope", async () => {
254+
dm.rawDiff = `@@ -10,6 +10,8 @@ enum FeatureFlag {
255+
- }
256+
+ /// https://app.asana.com/1/137249556945/project/1211834678943996/task/123456789
257+
+ case myNewFeature`
258+
259+
await featureFlagAsanaLink()
260+
expect(dm.warn).not.toHaveBeenCalled()
261+
})
262+
263+
it("warns when removed braces would have hidden a missing link", async () => {
264+
dm.rawDiff = `@@ -10,6 +10,8 @@ enum FeatureFlag {
265+
- }
266+
+ case myNewFeature`
267+
268+
await featureFlagAsanaLink()
269+
expect(dm.warn).toHaveBeenCalled()
270+
})
271+
272+
it("reports all cases missing links in a single warning", async () => {
273+
dm.rawDiff = `@@ -10,6 +10,10 @@ enum FeatureFlag {
274+
+ case firstFeature
275+
+ /// https://app.asana.com/1/137249556945/project/1211834678943996/task/123456789
276+
+ case validFeature
277+
+ case secondFeature`
278+
279+
await featureFlagAsanaLink()
280+
expect(dm.warn).toHaveBeenCalledTimes(1)
281+
const message = dm.warn.mock.calls[0][0]
282+
expect(message).toContain("firstFeature")
283+
expect(message).toContain("secondFeature")
284+
expect(message).not.toContain("validFeature")
285+
})
286+
})

0 commit comments

Comments
 (0)