Skip to content

Commit fa7d172

Browse files
committed
Improve review checklist in PR template
1 parent 2a0ed71 commit fa7d172

6 files changed

Lines changed: 405 additions & 79 deletions

.github/scripts/event-issue-helpers.mjs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,112 @@ export function generateUniqueUid(existingUids) {
154154
existingUids.add(uid);
155155
return uid;
156156
}
157+
158+
// ── PR body helpers ────────────────────────────────────────────────────────────
159+
160+
export const PR_FIELDS = [
161+
{ label: 'Event name', accessor: (d) => d.event_name },
162+
{ label: 'Contact', accessor: (d) => {
163+
const name = d.primary_contact?.name?.trim();
164+
const email = d.primary_contact?.email?.trim();
165+
if (name && email) return `${name} (${email})`;
166+
if (name) return name;
167+
if (email) return email;
168+
return '';
169+
}},
170+
{ label: 'Format', accessor: (d) => d.online_event ? 'Online' : 'In person' },
171+
{ label: 'Date', accessor: (d) => d.event_date },
172+
{ label: 'End date', accessor: (d) => d.event_end_date },
173+
{ label: 'Start time', accessor: (d) => d.event_start_time },
174+
{ label: 'End time', accessor: (d) => d.event_end_time },
175+
{ label: 'Address', accessor: (d) => d.event_location?.address },
176+
{ label: 'Plus Code', accessor: (d) => d.event_location?.plus_code ? `\`${d.event_location.plus_code}\`` : '' },
177+
{ label: 'City', accessor: (d) => d.city },
178+
{ label: 'Country', accessor: (d) => d.country },
179+
{ label: 'Organization', accessor: (d) => d.organization_name },
180+
{ label: 'Organization URL', accessor: (d) => d.organization_url },
181+
{ label: 'Organization type', accessor: (d) => d.organization_type },
182+
{ label: 'Event URL', accessor: (d) => d.event_url },
183+
{ label: 'Event page', accessor: (d) => d.event_page_url },
184+
{ label: 'Forum thread', accessor: (d) => d.forum_thread_url },
185+
{ label: 'Short description', accessor: (d) => d.event_short_description },
186+
{ label: 'Activities', accessor: (d) => [...(d.event_activities ?? [])].sort().join(', ') },
187+
{ label: 'Organizers', accessor: (d) => (d.organizers ?? []).map(o => o.name).join(', ') },
188+
];
189+
190+
export function escapeTableCell(value) {
191+
return String(value ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
192+
}
193+
194+
export function eventDataToDisplayMap(record) {
195+
const map = new Map();
196+
for (const { label, accessor } of PR_FIELDS) {
197+
const raw = accessor(record);
198+
const isEmpty = raw === undefined || raw === null || (typeof raw === 'string' && raw.trim() === '');
199+
map.set(label, isEmpty ? '_No response_' : escapeTableCell(raw));
200+
}
201+
return map;
202+
}
203+
204+
export function buildNewEventTable(displayMap) {
205+
const rows = ['| Field | Value |', '|---|---|'];
206+
for (const { label } of PR_FIELDS) {
207+
rows.push(`| ${label} | ${displayMap.get(label)} |`);
208+
}
209+
return rows.join('\n');
210+
}
211+
212+
export function buildEditEventTable(previousMap, newMap) {
213+
const rows = [];
214+
for (const { label } of PR_FIELDS) {
215+
const prev = previousMap.get(label);
216+
const next = newMap.get(label);
217+
if (prev !== next) {
218+
rows.push(`| ${label} | ${prev} | ${next} |`);
219+
}
220+
}
221+
if (rows.length === 0) return '_No metadata changes._';
222+
return ['| Field | Previous | New |', '|---|---|---|', ...rows].join('\n');
223+
}
224+
225+
export function formatLongDescription(text) {
226+
if (!text || !text.trim()) return '_No response_';
227+
return text.replace(/\r\n/g, '\n').split('\n').map(line => `> ${line}`).join('\n');
228+
}
229+
230+
export function buildPlusCodeNoteBlocks(plusCodeNote, rawPlusCode, resolvedPlusCode) {
231+
if (!plusCodeNote) return [];
232+
return [`> [!NOTE]\n> The Plus Code was auto-recovered from the user's input (\`${rawPlusCode}\`) using the city as a reference. Please verify the map pin placement is correct (https://plus.codes/${resolvedPlusCode}).`];
233+
}
234+
235+
export function buildPrBody({ mode, number, eventName, submitterLogin, plusCodeForLink, dataTable, longDescriptionSection, noteBlocks = [] }) {
236+
const submitterMention = submitterLogin ? `@${submitterLogin}` : 'the submitter';
237+
const isNew = mode === 'new';
238+
const formType = isNew ? 'New Event' : 'Edit Event';
239+
const checklistItem = isNew ? 'The event data below is accurate' : 'The changes below are correct';
240+
const tableSection = isNew ? '### Event data' : '### Changes';
241+
242+
const lines = [
243+
...noteBlocks.flatMap(b => [b, '']),
244+
`Closes #${number}`,
245+
'',
246+
`This PR was generated from the "${formType}" issue form for **${eventName}**.`,
247+
`Submitted by ${submitterMention}.`,
248+
'',
249+
'### Review checklist',
250+
'',
251+
`- [ ] ${checklistItem}`,
252+
...(plusCodeForLink ? [`- [ ] The map pin is placed correctly ([check Plus Code](https://plus.codes/${plusCodeForLink}))`] : []),
253+
'- [ ] The event displays correctly in the **Netlify preview** (link posted by the Netlify bot)',
254+
'',
255+
tableSection,
256+
'',
257+
dataTable,
258+
];
259+
260+
if (longDescriptionSection) {
261+
lines.push('', '### Long description', '', longDescriptionSection);
262+
}
263+
264+
return lines.join('\n');
265+
}

.github/scripts/event-issue-helpers.test.mjs

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import {
1010
parseActivities,
1111
parseOrganizers,
1212
generateUniqueUid,
13+
escapeTableCell,
14+
eventDataToDisplayMap,
15+
buildNewEventTable,
16+
buildEditEventTable,
17+
formatLongDescription,
18+
buildPlusCodeNoteBlocks,
19+
buildPrBody,
1320
} from './event-issue-helpers.mjs';
1421

1522
// ── parseIssueSections ────────────────────────────────────────────────────────
@@ -178,3 +185,235 @@ describe('generateUniqueUid', () => {
178185
assert.equal(uids.size, 20);
179186
});
180187
});
188+
189+
// ── escapeTableCell ───────────────────────────────────────────────────────────
190+
191+
describe('escapeTableCell', () => {
192+
test('escapes pipe characters', () => {
193+
assert.equal(escapeTableCell('a | b'), 'a \\| b');
194+
});
195+
196+
test('replaces newlines with spaces', () => {
197+
assert.equal(escapeTableCell('line1\nline2'), 'line1 line2');
198+
});
199+
200+
test('handles null/undefined as empty string', () => {
201+
assert.equal(escapeTableCell(null), '');
202+
assert.equal(escapeTableCell(undefined), '');
203+
});
204+
});
205+
206+
// ── eventDataToDisplayMap ─────────────────────────────────────────────────────
207+
208+
describe('eventDataToDisplayMap', () => {
209+
const baseRecord = {
210+
event_name: 'PCD @ Tokyo',
211+
primary_contact: { name: 'Jane Doe', email: 'jane@example.com' },
212+
online_event: false,
213+
event_date: '',
214+
event_end_date: undefined,
215+
event_start_time: '',
216+
event_end_time: '',
217+
event_location: { address: '123 Main St', plus_code: '8FW4V75V+8Q' },
218+
city: 'Tokyo',
219+
country: 'Japan',
220+
organization_name: '',
221+
organization_url: '',
222+
organization_type: '',
223+
event_url: '',
224+
event_page_url: '',
225+
forum_thread_url: '',
226+
event_short_description: 'A gathering.',
227+
event_activities: ['Live coding', 'Exhibition'],
228+
organizers: [{ name: 'Jane Doe' }, { name: 'John Smith' }],
229+
};
230+
231+
test('returns Map with correct labels and values', () => {
232+
const map = eventDataToDisplayMap(baseRecord);
233+
assert.equal(map.get('Event name'), 'PCD @ Tokyo');
234+
assert.equal(map.get('Contact'), 'Jane Doe (jane@example.com)');
235+
assert.equal(map.get('Format'), 'In person');
236+
assert.equal(map.get('City'), 'Tokyo');
237+
assert.equal(map.get('Country'), 'Japan');
238+
assert.equal(map.get('Address'), '123 Main St');
239+
assert.equal(map.get('Plus Code'), '`8FW4V75V+8Q`');
240+
assert.equal(map.get('Short description'), 'A gathering.');
241+
assert.equal(map.get('Organizers'), 'Jane Doe, John Smith');
242+
});
243+
244+
test('empty string maps to _No response_', () => {
245+
const map = eventDataToDisplayMap(baseRecord);
246+
assert.equal(map.get('Date'), '_No response_');
247+
assert.equal(map.get('Organization'), '_No response_');
248+
});
249+
250+
test('undefined maps to _No response_', () => {
251+
const map = eventDataToDisplayMap(baseRecord);
252+
assert.equal(map.get('End date'), '_No response_');
253+
});
254+
255+
test('false is NOT treated as blank (Format: Online)', () => {
256+
const map = eventDataToDisplayMap({ ...baseRecord, online_event: false });
257+
assert.equal(map.get('Format'), 'In person');
258+
});
259+
260+
test('activities are sorted for deterministic output', () => {
261+
const map = eventDataToDisplayMap({ ...baseRecord, event_activities: ['Exhibition', 'Live coding'] });
262+
assert.equal(map.get('Activities'), 'Exhibition, Live coding');
263+
});
264+
});
265+
266+
// ── buildNewEventTable ────────────────────────────────────────────────────────
267+
268+
describe('buildNewEventTable', () => {
269+
test('produces correct markdown table with Field/Value headers', () => {
270+
const record = {
271+
event_name: 'PCD @ Tokyo',
272+
primary_contact: { name: 'Jane', email: 'jane@example.com' },
273+
online_event: false,
274+
event_date: '', event_end_date: undefined, event_start_time: '', event_end_time: '',
275+
event_location: { address: '', plus_code: '' },
276+
city: '', country: '', organization_name: '', organization_url: '',
277+
organization_type: '', event_url: '', event_page_url: '', forum_thread_url: '',
278+
event_short_description: '', event_activities: [], organizers: [],
279+
};
280+
const table = buildNewEventTable(eventDataToDisplayMap(record));
281+
assert.ok(table.startsWith('| Field | Value |'), 'should start with header row');
282+
assert.ok(table.includes('|---|---|'), 'should include separator');
283+
assert.ok(table.includes('| Event name | PCD @ Tokyo |'), 'should include event name row');
284+
});
285+
});
286+
287+
// ── buildEditEventTable ───────────────────────────────────────────────────────
288+
289+
describe('buildEditEventTable', () => {
290+
const baseRecord = {
291+
event_name: 'PCD @ Tokyo',
292+
primary_contact: { name: 'Jane', email: 'jane@example.com' },
293+
online_event: false,
294+
event_date: '2026-03-21', event_end_date: undefined, event_start_time: '', event_end_time: '',
295+
event_location: { address: '123 Main St', plus_code: '8FW4V75V+8Q' },
296+
city: 'Tokyo', country: 'Japan', organization_name: '', organization_url: '',
297+
organization_type: '', event_url: '', event_page_url: '', forum_thread_url: '',
298+
event_short_description: 'A gathering.', event_activities: ['Live coding'], organizers: [],
299+
};
300+
301+
test('only includes rows where values differ', () => {
302+
const prev = eventDataToDisplayMap({ ...baseRecord, event_date: '2026-01-01' });
303+
const next = eventDataToDisplayMap({ ...baseRecord, event_date: '2026-03-21' });
304+
const table = buildEditEventTable(prev, next);
305+
assert.ok(table.includes('| Field | Previous | New |'), 'should have 3-column header');
306+
assert.ok(table.includes('| Date |'), 'should include changed Date row');
307+
assert.ok(!table.includes('| Event name |'), 'should not include unchanged Event name');
308+
});
309+
310+
test('returns fallback message when nothing changed', () => {
311+
const map = eventDataToDisplayMap(baseRecord);
312+
assert.equal(buildEditEventTable(map, map), '_No metadata changes._');
313+
});
314+
315+
test('activities compared deterministically (order-insensitive)', () => {
316+
const prev = eventDataToDisplayMap({ ...baseRecord, event_activities: ['Exhibition', 'Live coding'] });
317+
const next = eventDataToDisplayMap({ ...baseRecord, event_activities: ['Live coding', 'Exhibition'] });
318+
assert.equal(buildEditEventTable(prev, next), '_No metadata changes._');
319+
});
320+
});
321+
322+
// ── formatLongDescription ─────────────────────────────────────────────────────
323+
324+
describe('formatLongDescription', () => {
325+
test('prefixes each line with > ', () => {
326+
assert.equal(formatLongDescription('line1\nline2'), '> line1\n> line2');
327+
});
328+
329+
test('single line', () => {
330+
assert.equal(formatLongDescription('Hello world'), '> Hello world');
331+
});
332+
333+
test('blank/falsy returns _No response_', () => {
334+
assert.equal(formatLongDescription(''), '_No response_');
335+
assert.equal(formatLongDescription(null), '_No response_');
336+
assert.equal(formatLongDescription(undefined), '_No response_');
337+
assert.equal(formatLongDescription(' '), '_No response_');
338+
});
339+
340+
test('normalizes \\r\\n to \\n', () => {
341+
assert.equal(formatLongDescription('line1\r\nline2'), '> line1\n> line2');
342+
});
343+
});
344+
345+
// ── buildPlusCodeNoteBlocks ──────────────────────────────────────────────────
346+
347+
describe('buildPlusCodeNoteBlocks', () => {
348+
test('returns note block when plusCodeNote is truthy', () => {
349+
const blocks = buildPlusCodeNoteBlocks(true, 'V75V+8Q', '8FW4V75V+8Q');
350+
assert.equal(blocks.length, 1);
351+
assert.ok(blocks[0].includes('> [!NOTE]'));
352+
assert.ok(blocks[0].includes('`V75V+8Q`'));
353+
assert.ok(blocks[0].includes('https://plus.codes/8FW4V75V+8Q'));
354+
});
355+
356+
test('returns empty array when plusCodeNote is falsy', () => {
357+
assert.deepEqual(buildPlusCodeNoteBlocks(false, 'V75V+8Q', '8FW4V75V+8Q'), []);
358+
assert.deepEqual(buildPlusCodeNoteBlocks(null, 'V75V+8Q', '8FW4V75V+8Q'), []);
359+
});
360+
});
361+
362+
// ── buildPrBody ───────────────────────────────────────────────────────────────
363+
364+
describe('buildPrBody', () => {
365+
const base = {
366+
number: 42,
367+
eventName: 'PCD @ Tokyo',
368+
submitterLogin: 'someuser',
369+
plusCodeForLink: '8FW4V75V+8Q',
370+
dataTable: '| Field | Value |\n|---|---|\n| Event name | PCD @ Tokyo |',
371+
longDescriptionSection: null,
372+
noteBlocks: [],
373+
};
374+
375+
test('new event body has correct structure', () => {
376+
const body = buildPrBody({ mode: 'new', ...base });
377+
assert.ok(body.includes('Closes #42'));
378+
assert.ok(body.includes('"New Event" issue form'));
379+
assert.ok(body.includes('### Review checklist'));
380+
assert.ok(body.includes('The event data below is accurate'));
381+
assert.ok(body.includes('https://plus.codes/8FW4V75V+8Q'));
382+
assert.ok(body.includes('### Event data'));
383+
assert.ok(body.includes('| Field | Value |'));
384+
assert.ok(!body.includes('### Long description'), 'should not include long description section when null');
385+
});
386+
387+
test('edit event body has correct structure', () => {
388+
const body = buildPrBody({ mode: 'edit', ...base, dataTable: '| Field | Previous | New |\n|---|---|---|\n| Date | old | new |' });
389+
assert.ok(body.includes('"Edit Event" issue form'));
390+
assert.ok(body.includes('The changes below are correct'));
391+
assert.ok(body.includes('### Changes'));
392+
});
393+
394+
test('long description section included when provided', () => {
395+
const body = buildPrBody({ mode: 'new', ...base, longDescriptionSection: '> Some description.' });
396+
assert.ok(body.includes('### Long description'));
397+
assert.ok(body.includes('> Some description.'));
398+
});
399+
400+
test('noteBlocks appear before Closes line', () => {
401+
const body = buildPrBody({
402+
mode: 'new', ...base,
403+
noteBlocks: ['> [!NOTE]\n> The Plus Code was auto-recovered.'],
404+
});
405+
const noteIdx = body.indexOf('> [!NOTE]');
406+
const closesIdx = body.indexOf('Closes #42');
407+
assert.ok(noteIdx < closesIdx, 'note should appear before Closes line');
408+
});
409+
410+
test('submitter shown as @login when provided', () => {
411+
const body = buildPrBody({ mode: 'new', ...base });
412+
assert.ok(body.includes('Submitted by @someuser'));
413+
});
414+
415+
test('submitter shown as "the submitter" when login is empty', () => {
416+
const body = buildPrBody({ mode: 'new', ...base, submitterLogin: '' });
417+
assert.ok(body.includes('Submitted by the submitter'));
418+
});
419+
});

0 commit comments

Comments
 (0)