Skip to content

Commit 536a5c6

Browse files
committed
Merge branch 'main' of https://github.com/linuxfoundation/lfx-self-serve into feat/mentorship-mentor-register-connect
2 parents 3a70ac1 + 42cd528 commit 536a5c6

225 files changed

Lines changed: 15361 additions & 3118 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Copyright The Linux Foundation and each contributor to LFX.
2+
# SPDX-License-Identifier: MIT
3+
4+
name: Sync Issue Type From Label
5+
6+
on:
7+
issues:
8+
types: [labeled]
9+
10+
permissions:
11+
issues: write
12+
13+
jobs:
14+
sync-type:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Map label to issue type and update
18+
env:
19+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20+
LABEL_NAME: ${{ github.event.label.name }}
21+
ISSUE_NODE_ID: ${{ github.event.issue.node_id }}
22+
run: |
23+
# Issue type IDs are org-level (shared across all linuxfoundation repos with
24+
# Issue Types enabled), so they're stable to hardcode rather than look up per run.
25+
case "$LABEL_NAME" in
26+
bug | type:bug)
27+
TYPE_ID="IT_kwDOAA_egs4AAnET" # Bug
28+
;;
29+
enhancement | type:enhancement)
30+
TYPE_ID="IT_kwDOAA_egs4AAnEX" # Feature
31+
;;
32+
*)
33+
echo "No issue type mapping for label '$LABEL_NAME' — skipping."
34+
exit 0
35+
;;
36+
esac
37+
38+
gh api graphql -f query='
39+
mutation($issueId: ID!, $typeId: ID!) {
40+
updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $typeId }) {
41+
issue { id issueType { name } }
42+
}
43+
}' -f issueId="$ISSUE_NODE_ID" -f typeId="$TYPE_ID"

apps/lfx-one/e2e/fixtures/mock-data/formation-item.mock.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,13 @@ export const mockFormationActivity: Record<string, FormationActivity[]> = {
146146
'formation-item:cascade-data-alliance:contribution_agreement_executed': [
147147
{
148148
uid: 'formation-activity:1',
149-
formation_uid: 'formation:cascade-data-alliance',
150149
formation_item_uid: 'formation-item:cascade-data-alliance:contribution_agreement_executed',
151-
type: 'note_added',
150+
action: 'note_changed',
151+
action_raw: 'note_changed',
152+
set_by: 'user',
152153
actor: { username: 'sam.chen', name: 'Sam Chen' },
153-
message: 'updated notes',
154-
metadata: null,
154+
before: null,
155+
after: null,
155156
created_at: new Date(0).toISOString(),
156157
},
157158
],

apps/lfx-one/e2e/fixtures/mock-data/formation.mock.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export const mockFormations: Record<string, Formation> = {
3131
template_uid: SEEDED_FORMATION_TEMPLATE_UID,
3232
template_version: 1,
3333
sub_stage: 'engaged',
34-
announcement_date: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
34+
sub_stage_raw: 'Formation - Engaged',
35+
announcement_date: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
3536
is_activating: false,
3637
// Mirrors mockFormationItems['formation:cascade-data-alliance']: 2 gating items
3738
// (draft_project_record=done, contribution_agreement_executed=in_progress) — this same fixture
@@ -49,9 +50,12 @@ export const mockFormations: Record<string, Formation> = {
4950
/**
5051
* A queue-only list, independent of `mockFormations` — the Formations queue table (GH-1958) is
5152
* root-scoped, not tied to a single project-page test. Shaped as `FormationQueueRow` (GH-2267 gap
52-
* 2 — the real indexed queue projection, not the checklist-read `Formation` shape): `progress`
53-
* replaces `gating_items_open`/`gating_items_total`, and `gates_cleared` mirrors what each row's
54-
* old `is_activating` value implied (open === 0).
53+
* 2 — the BFF's post-normalization queue-row shape, not the checklist-read `Formation` shape and
54+
* not the raw upstream `UpstreamFormationQueueRow` the indexer publishes): `progress` replaces
55+
* `gating_items_open`/`gating_items_total`, `gates_cleared` mirrors what each row's old
56+
* `is_activating` value implied (open === 0), and `sub_stage`/`sub_stage_raw` (GH-2366) carry the
57+
* already-normalized short key and its verbatim upstream source, since these mocks stand in for
58+
* the BFF response, not the raw projection.
5559
*/
5660
export const mockFormationsQueue: FormationQueueRow[] = [
5761
{
@@ -62,6 +66,7 @@ export const mockFormationsQueue: FormationQueueRow[] = [
6266
is_foundation: mockFormations['cascade-data-alliance'].is_foundation,
6367
parent_uid: mockFormations['cascade-data-alliance'].parent_uid,
6468
sub_stage: mockFormations['cascade-data-alliance'].sub_stage,
69+
sub_stage_raw: 'Formation - Engaged',
6570
lifecycle: 'formation',
6671
gates_cleared: false,
6772
is_activating: false,
@@ -80,6 +85,7 @@ export const mockFormationsQueue: FormationQueueRow[] = [
8085
is_foundation: false,
8186
parent_uid: 'e19f1234-f567-4abc-b890-1234567890de',
8287
sub_stage: 'on_hold',
88+
sub_stage_raw: 'Formation - On Hold',
8389
lifecycle: 'formation',
8490
gates_cleared: false,
8591
is_activating: false,
@@ -96,10 +102,11 @@ export const mockFormationsQueue: FormationQueueRow[] = [
96102
is_foundation: false,
97103
parent_uid: 'e19f1234-f567-4abc-b890-1234567890de',
98104
sub_stage: 'engaged',
105+
sub_stage_raw: 'Formation - Engaged',
99106
lifecycle: 'formation',
100107
gates_cleared: true,
101108
is_activating: true,
102-
announcement_date: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(),
109+
announcement_date: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
103110
progress: { not_started: 0, in_progress: 0, blocked: 0, awaiting_acceptance: 0, done: 4, skipped: 0 },
104111
blocked_item_titles: [],
105112
assignees: [],

apps/lfx-one/e2e/formation-checklist-robust.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ test.describe('Formation checklist section — structural contract', () => {
187187
route.fulfill({
188188
status: 200,
189189
contentType: 'application/json',
190-
body: JSON.stringify({ formation: FORMATION, template: mockFormationTemplate, items: itemsWithLink, data_source: 'fixture' }),
190+
body: JSON.stringify({ formation: FORMATION, template: mockFormationTemplate, items: itemsWithLink }),
191191
})
192192
);
193193
await gotoProjectFormation(page, FORMATION_PROJECT_SLUG);

apps/lfx-one/e2e/formation-checklist.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ test.describe('Formation Checklist section (GH-1958)', () => {
6565

6666
const drawer = page.getByTestId('formation-item-drawer');
6767
await expect(drawer).toBeVisible();
68-
await expect(page.getByTestId('formation-item-drawer-history')).toContainText('updated notes');
68+
await expect(page.getByTestId('formation-item-drawer-history')).toContainText('updated the note');
6969
});
7070

7171
test('the "Choose a template" empty state renders when no template has been chosen', async ({ page }) => {
@@ -120,7 +120,7 @@ test.describe('Formation Checklist section (GH-1958)', () => {
120120
route.fulfill({
121121
status: 200,
122122
contentType: 'application/json',
123-
body: JSON.stringify({ formation, template: mockFormationTemplate, items, data_source: 'fixture' }),
123+
body: JSON.stringify({ formation, template: mockFormationTemplate, items }),
124124
})
125125
);
126126
await gotoProjectFormation(page, FORMATION_PROJECT_SLUG);

apps/lfx-one/e2e/helpers/formation-api-mock.helper.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export class FormationApiMockHelper {
2727
await route.fulfill({
2828
status: 200,
2929
contentType: 'application/json',
30-
body: JSON.stringify({ formation, template: mockFormationTemplate, items, data_source: 'fixture' }),
30+
body: JSON.stringify({ formation, template: mockFormationTemplate, items }),
3131
});
3232
});
3333
}
@@ -53,7 +53,7 @@ export class FormationApiMockHelper {
5353
await route.fulfill({
5454
status: 200,
5555
contentType: 'application/json',
56-
body: JSON.stringify({ item, history: mockFormationActivity[item.uid] ?? [] }),
56+
body: JSON.stringify({ item, history: mockFormationActivity[item.uid] ?? [], history_state: 'complete' }),
5757
});
5858
});
5959
}
@@ -93,9 +93,12 @@ export class FormationApiMockHelper {
9393
// Mirrors formation.service.ts's buildQueueTiles — a bare 'project' entity rolls into the
9494
// projects count so it isn't dropped from the breakdown while still counting toward total.
9595
projects: rows.filter((row) => deriveFormationEntityType(row) !== 'foundation').length,
96+
// GH-2366 — rows whose sub_stage has no queue-taxonomy equivalent; none of the fixture rows
97+
// are unmapped today, so this mirrors the real BFF's shape without changing any mocked count.
98+
unmapped: rows.filter((row) => row.sub_stage === null).length,
9699
};
97100

98-
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tiles, rows: filtered, data_source: 'fixture' }) });
101+
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tiles, rows: filtered }) });
99102
});
100103
}
101104

apps/lfx-one/e2e/helpers/formation-checklist.helper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export async function mockFormationChecklistApis(page: Page, opts: { project: Pr
8686
template_uid: 'seed',
8787
template_version: 1,
8888
sub_stage: 'engaged',
89+
sub_stage_raw: 'Formation - Engaged',
8990
announcement_date: null,
9091
is_activating: false,
9192
gating_items_open: 0,
@@ -97,7 +98,6 @@ export async function mockFormationChecklistApis(page: Page, opts: { project: Pr
9798
},
9899
template: state === 'no-template' ? null : { uid: 'seed', version: 1, name: 'Project formation', sections: [] },
99100
items: [],
100-
data_source: 'fixture',
101101
}),
102102
})
103103
);

apps/lfx-one/e2e/helpers/org-easycla.helper.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,81 @@ export function searchInput(page: Page): Locator {
168168
return page.locator('[data-test="org-easycla-search"]');
169169
}
170170

171+
// ---------------------------------------------------------------------------
172+
// The self-sign hand-off (GH-1983)
173+
// ---------------------------------------------------------------------------
174+
175+
/** The CLA Group search behind the picker. */
176+
export const SIGN_OPTIONS_ROUTE = '**/api/orgs/*/lens/cla-groups/sign-options*';
177+
178+
/** The write. Every spec below stubs it; none may ever let it reach a real CLA service. */
179+
export const SIGN_ROUTE = '**/api/orgs/*/lens/cla-groups/sign';
180+
181+
/**
182+
* Where the hand-off is told to send the signer.
183+
*
184+
* A synthetic address on a reserved domain, routed and fulfilled locally by `stubHandoff` so the
185+
* browser never leaves the app under test. A real signing address here would create a real
186+
* envelope against a real agreement on every run.
187+
*/
188+
export const STUB_SIGN_URL = 'https://signing.example.org/session/e2e-stub';
189+
190+
/**
191+
* The signature the stubbed hand-off says it opened.
192+
*
193+
* Deliberately the `id` of the default `claGroup()` row, because that is what makes the return
194+
* landing observable: the page only navigates to an agreement it can see in the organization's own
195+
* list, so a stub signature absent from the stubbed list would leave the signatory on it.
196+
*/
197+
export const STUB_SIGNATURE_ID = 'signature-uuid-1';
198+
199+
/** A searchable CLA Group, corporate-signable unless a case says otherwise. */
200+
export function signOption(overrides: Record<string, unknown> = {}) {
201+
return {
202+
claGroupId: 'aaaaaaaa-1111-4111-8111-111111111111',
203+
claGroupName: 'Cascade CLA',
204+
projectName: 'Cascade',
205+
projectSfid: 'a09410000182dD2AAI',
206+
cclaEnabled: true,
207+
iclaEnabled: true,
208+
matchTypes: ['project'],
209+
organizations: [],
210+
...overrides,
211+
};
212+
}
213+
214+
export function signOptionsResponse(results: ReturnType<typeof signOption>[], truncated = false) {
215+
return { searchTerm: 'cascade', resultCount: results.length, truncated, results };
216+
}
217+
218+
/**
219+
* Stubs the whole hand-off chain: the picker's search, the signature request, and the signing
220+
* address the request answers with.
221+
*
222+
* The last one is the important one and is not optional. The component assigns the returned
223+
* address to `location.href`, so without a route intercepting it the browser navigates away to
224+
* whatever the fixture said — and a fixture that ever named a real signing host would drive a
225+
* real DocuSign session from CI. Fulfilling it locally keeps the assertion (did we navigate to
226+
* exactly the address the server returned?) while the navigation lands on a blank local page.
227+
*/
228+
export async function stubHandoff(page: Page, options: { search?: unknown; sign?: { status: number; body: unknown }; signUrl?: string } = {}): Promise<void> {
229+
const signUrl = options.signUrl ?? STUB_SIGN_URL;
230+
231+
await fulfillJson(page, SIGN_OPTIONS_ROUTE, options.search ?? signOptionsResponse([signOption()]));
232+
233+
const sign = options.sign ?? { status: 200, body: { signUrl, signatureId: STUB_SIGNATURE_ID } };
234+
await page.route(SIGN_ROUTE, (route) => route.fulfill({ status: sign.status, contentType: 'application/json', body: JSON.stringify(sign.body) }));
235+
236+
await page.route(`${signUrl}**`, (route) =>
237+
route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body data-testid="stub-signing-service">stub signing service</body></html>' })
238+
);
239+
}
240+
241+
/** The picker's search box. Same `data-test` quirk as the list's, for the same reason. */
242+
export function groupSearchInput(page: Page): Locator {
243+
return page.locator('[data-test="org-easycla-group-select-search"]');
244+
}
245+
171246
/** Skips when the shared Playwright credentials are absent, as every authenticated spec does. */
172247
export function skipWithoutCredentials(): void {
173248
if (!process.env.TEST_USERNAME || !process.env.TEST_PASSWORD) {

apps/lfx-one/e2e/meeting-card-title-datetime-link.spec.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ async function gotoMyMeetings(page: Page): Promise<{ viewerLfid: string }> {
123123
const viewer = { name: 'E2E Viewer', username: viewerLfid, email: 'viewer-e2e@example.com' };
124124

125125
await page.route('**/api/user/meetings*', (route) => fulfillJson(route, [upcomingMeeting('link-up-1', 'Clickable Upcoming', viewer, { organizer: true })]));
126-
await page.route('**/api/user/past-meetings*', (route) => fulfillJson(route, [pastMeeting('link-past-1', 'Clickable Past', viewer, { organizer: true })]));
126+
await page.route('**/api/user/past-meetings*', (route) =>
127+
fulfillJson(route, [pastMeeting('link-past-1', 'Clickable Past', viewer, { organizer: true, project_slug: 'e2e-project' })])
128+
);
127129

128130
await page.goto(MEETINGS_URL, { waitUntil: 'domcontentloaded' });
129131
skipWhenAuthMissing(page);
@@ -168,7 +170,7 @@ test.describe('Meeting card — clickable title and date/time chip', () => {
168170
await chipPopup.close();
169171
});
170172

171-
test('past card: title and date chip navigate in-app (routerLink), not a new tab', async ({ page }) => {
173+
test('past card: organizer (Manage role) title and date chip navigate in-app to the admin details page', async ({ page }) => {
172174
await gotoMyMeetings(page);
173175
await pastTab(page).click();
174176

@@ -178,20 +180,66 @@ test.describe('Meeting card — clickable title and date/time chip', () => {
178180
const title = pastCard.getByTestId('meeting-title');
179181
const chip = pastCard.getByTestId('meeting-datetime');
180182

183+
// Fixture is seeded with organizer: true and is_foundation: false — Manage-role viewers
184+
// route to the admin details page, not the public join page (see #2251). The fixture's
185+
// project_slug must also survive onto the link so projectQueryParamGuard (which fails open
186+
// and skips seeding project/foundation context when `?project=` is absent) has what it needs.
181187
await expect(title).toHaveJSProperty('tagName', 'A');
182-
await expect(title).toHaveAttribute('href', '/meetings/link-past-1');
188+
await expect(title).toHaveAttribute('href', '/project/meetings/link-past-1/details?project=e2e-project');
183189
await expect(title).not.toHaveAttribute('target', '_blank');
184190

185191
await expect(chip).toHaveJSProperty('tagName', 'A');
186-
await expect(chip).toHaveAttribute('href', '/meetings/link-past-1');
192+
await expect(chip).toHaveAttribute('href', '/project/meetings/link-past-1/details?project=e2e-project');
187193
await expect(chip).not.toHaveAttribute('target', '_blank');
188194

189195
// Activate the title anchor — same-tab SPA nav, so only one of the two anchors is clicked here
190196
// (clicking both would require navigating back and re-deriving the past-tab state in between).
191197
// Chip's routerLink is already proven equivalent via the identical href assertion above.
192198
await title.click();
193199
await page.waitForURL((url) => !/^\/meetings\/?$/.test(url.pathname));
194-
expect(page.url()).toContain('/meetings/link-past-1');
200+
expect(page.url()).toContain('/project/meetings/link-past-1/details');
201+
expect(page.url()).toContain('project=e2e-project');
202+
});
203+
204+
test('past card: non-organizer (View role) title and date chip navigate to the public join/summary page', async ({ page }) => {
205+
await seedMeLensCookie(page);
206+
await stubMeLensContext(page);
207+
208+
await page.goto('/', { waitUntil: 'domcontentloaded' });
209+
skipWhenAuthMissing(page);
210+
211+
const viewerLfid = await readViewerLfid(page);
212+
if (!viewerLfid) {
213+
test.skip(true, 'Could not resolve the signed-in LFID from the SSR auth state');
214+
return;
215+
}
216+
217+
const viewer = { name: 'E2E Viewer', username: viewerLfid, email: 'viewer-e2e@example.com' };
218+
const otherCreator = { name: 'Other Organizer', username: 'other-organizer-e2e', email: 'other-organizer-e2e@example.com' };
219+
220+
await page.route('**/api/user/meetings*', (route) => fulfillJson(route, []));
221+
await page.route('**/api/user/past-meetings*', (route) =>
222+
fulfillJson(route, [pastMeeting('link-past-view-1', 'Clickable Past — View Role', otherCreator, { organizer: false })])
223+
);
224+
225+
await page.goto(MEETINGS_URL, { waitUntil: 'domcontentloaded' });
226+
skipWhenAuthMissing(page);
227+
await expect(page).not.toHaveURL(/auth0\.com/);
228+
if (!page.url().includes('/meetings')) {
229+
test.skip(true, 'Me lens is not available for this user — /meetings redirected away');
230+
return;
231+
}
232+
233+
await pastTab(page).click();
234+
235+
const pastCard = card(page, 'link-past-view-1');
236+
await expect(pastCard).toBeVisible();
237+
238+
const title = pastCard.getByTestId('meeting-title');
239+
const chip = pastCard.getByTestId('meeting-datetime');
240+
241+
await expect(title).toHaveAttribute('href', '/meetings/link-past-view-1');
242+
await expect(chip).toHaveAttribute('href', '/meetings/link-past-view-1');
195243
});
196244

197245
test('clicking inner card actions does not trigger a details navigation or open a new tab', async ({ page, context }) => {

0 commit comments

Comments
 (0)