Skip to content

Commit 4370345

Browse files
authored
chore(preferences): add workflow for github comment for mms feature flag on feature flag add COMPASS-10691 (#8157)
1 parent 53ff6f8 commit 4370345

6 files changed

Lines changed: 318 additions & 7 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: Feature Flag MMS PR Creation
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'packages/compass-preferences-model/src/feature-flags.ts'
7+
types: [opened, synchronize, reopened]
8+
9+
jobs:
10+
detect-and-comment:
11+
name: Detect new feature flags and comment on PR
12+
runs-on: ubuntu-latest
13+
permissions:
14+
pull-requests: write
15+
contents: read
16+
steps:
17+
- uses: actions/checkout@v4
18+
with:
19+
fetch-depth: 0
20+
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: 24.15.0
24+
cache: 'npm'
25+
26+
- name: Install npm@11.16.0
27+
run: |
28+
npm install -g npm@11.16.0
29+
30+
- name: Install Dependencies
31+
run: |
32+
npm -v
33+
npm ci
34+
35+
- name: Detect new feature flags
36+
id: detect
37+
run: node packages/compass-preferences-model/scripts/detect-new-feature-flags-for-mms.mts
38+
env:
39+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
40+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
41+
42+
- name: Post or update comment on PR
43+
if: steps.detect.outputs.flags_count != '' && steps.detect.outputs.flags_count != '0'
44+
uses: actions/github-script@v7
45+
env:
46+
COMMENT_BODY: ${{ steps.detect.outputs.comment_body }}
47+
with:
48+
script: |
49+
const marker = '<!-- feature-flag-mms-comment -->';
50+
const body = marker + '\n' + process.env.COMMENT_BODY;
51+
52+
const { data: comments } = await github.rest.issues.listComments({
53+
owner: context.repo.owner,
54+
repo: context.repo.repo,
55+
issue_number: context.issue.number,
56+
});
57+
58+
const existing = comments.find(c => c.body.includes(marker));
59+
60+
if (existing) {
61+
await github.rest.issues.updateComment({
62+
owner: context.repo.owner,
63+
repo: context.repo.repo,
64+
comment_id: existing.id,
65+
body,
66+
});
67+
} else {
68+
await github.rest.issues.createComment({
69+
owner: context.repo.owner,
70+
repo: context.repo.repo,
71+
issue_number: context.issue.number,
72+
body,
73+
});
74+
}

package-lock.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/compass-preferences-model/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"chai": "^4.3.6",
7373
"depcheck": "^1.4.1",
7474
"mocha": "^10.2.0",
75-
"sinon": "^9.2.3"
75+
"sinon": "^9.2.3",
76+
"typescript": "^5.9.3"
7677
},
7778
"private": true
7879
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env node
2+
import ts from 'typescript';
3+
import { spawnSync } from 'child_process';
4+
import { appendFileSync } from 'fs';
5+
6+
const FILEPATH = 'packages/compass-preferences-model/src/feature-flags.ts';
7+
8+
interface FlagInfo {
9+
name: string;
10+
scope: string | null;
11+
description: string | null;
12+
}
13+
14+
function getFileAt(sha: string): string | null {
15+
const r = spawnSync('git', ['show', `${sha}:${FILEPATH}`], {
16+
encoding: 'utf8',
17+
});
18+
return r.status === 0 ? r.stdout : null;
19+
}
20+
21+
function getStringProp(
22+
obj: ts.ObjectLiteralExpression,
23+
key: string
24+
): string | null {
25+
for (const prop of obj.properties) {
26+
if (
27+
ts.isPropertyAssignment(prop) &&
28+
ts.isIdentifier(prop.name) &&
29+
prop.name.text === key &&
30+
ts.isStringLiteral(prop.initializer)
31+
) {
32+
return prop.initializer.text;
33+
}
34+
}
35+
return null;
36+
}
37+
38+
function getObjectProp(
39+
obj: ts.ObjectLiteralExpression,
40+
key: string
41+
): ts.ObjectLiteralExpression | null {
42+
for (const prop of obj.properties) {
43+
if (
44+
ts.isPropertyAssignment(prop) &&
45+
ts.isIdentifier(prop.name) &&
46+
prop.name.text === key &&
47+
ts.isObjectLiteralExpression(prop.initializer)
48+
) {
49+
return prop.initializer;
50+
}
51+
}
52+
return null;
53+
}
54+
55+
function extractFlags(source: string): Map<string, FlagInfo> {
56+
const sourceFile = ts.createSourceFile(
57+
'feature-flags.ts',
58+
source,
59+
ts.ScriptTarget.Latest,
60+
/* setParentNodes */ true
61+
);
62+
const flags = new Map<string, FlagInfo>();
63+
64+
function visit(node: ts.Node): void {
65+
if (
66+
ts.isVariableDeclaration(node) &&
67+
ts.isIdentifier(node.name) &&
68+
node.name.text === 'FEATURE_FLAG_DEFINITIONS' &&
69+
node.initializer
70+
) {
71+
// Unwrap "as const satisfies ..." to reach the ArrayLiteralExpression.
72+
let expr: ts.Expression = node.initializer;
73+
while (ts.isSatisfiesExpression(expr) || ts.isAsExpression(expr)) {
74+
expr = expr.expression;
75+
}
76+
77+
if (ts.isArrayLiteralExpression(expr)) {
78+
for (const element of expr.elements) {
79+
if (!ts.isObjectLiteralExpression(element)) continue;
80+
const name = getStringProp(element, 'name');
81+
if (!name) continue;
82+
const descObj = getObjectProp(element, 'description');
83+
const scope = getStringProp(element, 'atlasCloudFeatureScope');
84+
if (!scope) {
85+
// Only extract new feature flags to be used in Atlas.
86+
continue;
87+
}
88+
flags.set(name, {
89+
name,
90+
scope,
91+
description: descObj ? getStringProp(descObj, 'short') : null,
92+
});
93+
}
94+
return;
95+
}
96+
}
97+
ts.forEachChild(node, visit);
98+
}
99+
100+
visit(sourceFile);
101+
return flags;
102+
}
103+
104+
function resolveRef(ref: string): string | null {
105+
const r = spawnSync('git', ['rev-parse', ref], { encoding: 'utf8' });
106+
return r.status === 0 ? r.stdout.trim() : null;
107+
}
108+
109+
function buildCommentBody(flags: FlagInfo[]): string {
110+
const flagSummaries = flags
111+
.map(
112+
(flag) => `### \`${flag.name}\`
113+
- **Description:** ${
114+
flag.description ??
115+
'_Not set._ Please add a description object with at least a short property to the feature flag definition in `feature-flags.ts` so it can be used in the MMS feature flag definition.'
116+
}
117+
- **Atlas Cloud Scope:** \`${flag.scope}\``
118+
)
119+
.join('\n\n');
120+
121+
const flagDefinitions = flags
122+
.map(
123+
(
124+
flag
125+
) => `\tFile: [feature-flags/definitions/developer-tools](https://github.com/10gen/mms/tree/master/feature-flags/definitions/developer-tools)/data-explorer-compass-web-${flag.name}.yml
126+
\tContents:
127+
128+
\`\`\`yml
129+
name: mms.featureFlag.dataExplorerCompassWeb.${flag.name}
130+
namespace: global
131+
scope: ${flag.scope}
132+
description: ${flag.description}
133+
phases:
134+
local: enabled
135+
local-gov: disabled
136+
test: controlled
137+
test-gov: disabled
138+
dev: controlled
139+
dev-gov: disabled
140+
qa: controlled
141+
qa-gov: disabled
142+
stage: controlled
143+
prod: controlled
144+
prod-gov: disabled
145+
internal: disabled
146+
\`\`\``
147+
)
148+
.join('\n\n');
149+
150+
return `## New Feature Flag Definition(s) Detected
151+
152+
The following new feature flag(s) were added to \`FEATURE_FLAG_DEFINITIONS\` in \`feature-flags.ts\`:
153+
154+
${flagSummaries}
155+
156+
---
157+
158+
**As a follow up, create MMS feature flag PR for each new feature flag**. Steps:
159+
\t1. Create a new file in the directory [feature-flags/definitions/developer-tools](https://github.com/10gen/mms/tree/master/feature-flags/definitions/developer-tools)
160+
\t2. Add the contents of the feature flag definition to it. Here is the title and contents for the new flag(s):
161+
162+
${flagDefinitions}
163+
`;
164+
}
165+
166+
function main(): void {
167+
const { BASE_SHA, HEAD_SHA, GITHUB_OUTPUT: githubOutput = '' } = process.env;
168+
169+
// Fall back to local refs when running outside CI.
170+
const headSha = HEAD_SHA ?? resolveRef('HEAD');
171+
const baseSha = BASE_SHA ?? resolveRef('origin/main') ?? resolveRef('main');
172+
173+
if (!headSha) {
174+
console.error('Could not resolve HEAD SHA');
175+
process.exit(1);
176+
}
177+
if (!baseSha) {
178+
console.error(
179+
'Could not resolve base SHA (set BASE_SHA or ensure origin/main exists)'
180+
);
181+
process.exit(1);
182+
}
183+
184+
const mergeBaseResult = spawnSync('git', ['merge-base', baseSha, headSha], {
185+
encoding: 'utf8',
186+
});
187+
const mergeBase =
188+
mergeBaseResult.status === 0 ? mergeBaseResult.stdout.trim() : baseSha;
189+
190+
const headSource = getFileAt(headSha);
191+
if (!headSource) {
192+
console.error(`Could not read ${FILEPATH} at ${headSha}`);
193+
process.exit(1);
194+
}
195+
196+
const baseSource = getFileAt(mergeBase);
197+
const baseFlags = baseSource ? extractFlags(baseSource) : new Map();
198+
199+
const headFlags = extractFlags(headSource);
200+
const newFlags = [...headFlags.values()].filter(
201+
(f) => !baseFlags.has(f.name)
202+
);
203+
204+
console.log(
205+
newFlags.length
206+
? `New feature flags detected: ${newFlags.map((f) => f.name).join(', ')}`
207+
: 'No new feature flags detected.'
208+
);
209+
210+
if (githubOutput) {
211+
appendFileSync(githubOutput, `flags_count=${newFlags.length}\n`);
212+
if (newFlags.length > 0) {
213+
const delimiter = `ghadelimiter_${Math.random().toString(36).slice(2)}`;
214+
const body = buildCommentBody(newFlags);
215+
appendFileSync(
216+
githubOutput,
217+
`comment_body<<${delimiter}\n${body}\n${delimiter}\n`
218+
);
219+
}
220+
} else {
221+
console.log(newFlags);
222+
}
223+
}
224+
225+
main();

packages/compass-preferences-model/src/feature-flags.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ export type FeatureFlagDefinition = {
1818
*/
1919
stage: 'development' | 'preview' | 'released';
2020
/**
21-
* Optional field that will be (COMPASS-10691) used to specify the scope
22-
* of the feature flag for Atlas Cloud.
21+
* Optional field that is used to specify the scope of the
22+
* feature flag for Atlas Cloud. Supply this when the feature flag is intended
23+
* to be set in Atlas and not scoped to Compass.
2324
*/
2425
atlasCloudFeatureScope?: 'group' | 'organization';
2526
description: {

packages/compass-web/src/preferences.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,13 @@ const getPermissionsFromUserRoles = (userRoles: {
124124
* @internal Exported for testing.
125125
*/
126126
export async function getPreferencesFromCloudApi(projectId: string) {
127-
const { featureFlags, userAuid, appUser, currentOrganization, userRoles } =
128-
await _fetchPreferencesFromCloudApi(projectId);
127+
const {
128+
featureFlags: featureFlagsAndPreferences,
129+
userAuid,
130+
appUser,
131+
currentOrganization,
132+
userRoles,
133+
} = await _fetchPreferencesFromCloudApi(projectId);
129134

130135
const atlasCloudUserPreferences: Partial<AllPreferences> = {
131136
atlasServiceBackendPreset: getAtlasServiceBackendPreset(),
@@ -141,7 +146,11 @@ export async function getPreferencesFromCloudApi(projectId: string) {
141146

142147
// Cloud feature flags arrive keyed by their Compass preference name. We
143148
// override Compass' value to resolve to the cloud value.
144-
for (const [name, enabled] of Object.entries(featureFlags)) {
149+
// Note: Things we would consider preferences in Compass are feature flags in
150+
// mms. For instance, settings on the project that enable or disable features
151+
// for users of that project are feature flags in mms. As a result, the properties in
152+
// this `featureFlags` object are a mix of feature flags and preferences from Compass' perspective.
153+
for (const [name, enabled] of Object.entries(featureFlagsAndPreferences)) {
145154
// Filter the feature flags that are not defined in Compass' preferences schema.
146155
if (!isPreferenceNameValid(name)) {
147156
continue;

0 commit comments

Comments
 (0)