Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/ISSUE_TEMPLATE/update-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Mettre à jour mes informations
description: Modifier votre nom, photo, profil LinkedIn, site ou description dans l'annuaire des certifiés.
title: "[Certifié] Mise à jour de mes informations"
labels: ["update-request"]
body:
- type: markdown
attributes:
value: |
Modifiez uniquement ce que vous voulez changer. **Un champ laissé vide reste inchangé.**

Votre identité est le compte GitHub avec lequel vous ouvrez cette demande ; elle n'est pas modifiable ici. Votre badge et sa date de certification ne changent pas : seule votre fiche d'annuaire est mise à jour. Un mainteneur relit la modification avant publication.
- type: input
id: name
attributes:
label: Nom complet
description: Laissez vide pour garder le nom actuel.
placeholder: Prénom Nom
validations:
required: false
- type: input
id: linkedin
attributes:
label: Profil LinkedIn
description: L'adresse complète de votre profil LinkedIn. Laissez vide pour garder l'actuel.
placeholder: https://www.linkedin.com/in/votre-profil
validations:
required: false
- type: input
id: website
attributes:
label: Site web
description: Votre site personnel. Laissez vide pour garder l'actuel.
placeholder: https://votre-site.fr
validations:
required: false
- type: input
id: description
attributes:
label: Description
description: Une phrase de présentation. Laissez vide pour garder l'actuelle.
placeholder: "Développeur passionné d'IA appliquée au dev."
validations:
required: false
- type: textarea
id: photo
attributes:
label: Photo
description: Glissez une nouvelle photo pour remplacer l'actuelle. Laissez vide pour la garder.
validations:
required: false
- type: checkboxes
id: consent
attributes:
label: Exactitude et publication
options:
- label: Je contrôle ce compte GitHub et les informations que je fournis sont exactes.
required: true
- label: J'accepte que ces informations mises à jour soient publiées dans l'annuaire public.
required: true
27 changes: 16 additions & 11 deletions .github/scripts/lib/certification-request.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { clean, parseIssueFormBody } from './issue-form.mjs';
import { extractImageUrl } from './photo-url.mjs';
import { recordPathFor, photoPathFor } from './member-paths.mjs';

const NO_RESPONSE = '_No response_';
export const NO_RESPONSE = '_No response_';

const LIMIT = Object.freeze({ name: 120, linkedin: 200, website: 200, description: 280 });
export const LIMIT = Object.freeze({ name: 120, linkedin: 200, website: 200, description: 280 });

// Le heading d'un champ Issue Form = son `label`, rendu en `### <label>`.
const HEADING = Object.freeze({
export const HEADING = Object.freeze({
name: 'nom complet',
linkedin: 'profil linkedin',
website: 'site web',
Expand All @@ -23,24 +23,24 @@ const GITHUB_HANDLE_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/;
/** Erreur de validation d'une demande : message destiné au demandeur. */
export class RequestError extends Error {}

const omitNoResponse = (value) => (value === NO_RESPONSE ? '' : value);
export const omitNoResponse = (value) => (value === NO_RESPONSE ? '' : value);

function requireSingleLine(value, field) {
export function requireSingleLine(value, field) {
if (value.includes('\n')) throw new RequestError(`${field} doit tenir sur une ligne`);
return value;
}

function requireUnderLimit(value, field, max) {
export function requireUnderLimit(value, field, max) {
if (value.length > max) throw new RequestError(`${field} dépasse ${max} caractères`);
return value;
}

function isHttpsUrl(value) {
export function isHttpsUrl(value) {
try { return new URL(value).protocol === 'https:'; } catch { return false; }
}

/** Scalaire YAML sûr : double-quote via JSON (échappe guillemets, backslash, etc.). */
const yamlScalar = (value) => JSON.stringify(String(value));
export const yamlScalar = (value) => JSON.stringify(String(value));

/** Valide le handle GitHub (issu de l'auteur de l'issue, pas d'un champ saisi). */
export function parseHandle(login) {
Expand Down Expand Up @@ -90,19 +90,24 @@ export function parseDescription(fields) {
return requireUnderLimit(value, 'Description', LIMIT.description);
}

/** Sérialise l'enregistrement du membre en YAML plat, valeurs échappées. */
export function toMemberYaml({ handle, name, linkedin, website, description, statusIndex }) {
/**
* Sérialise l'enregistrement du membre en YAML plat, valeurs échappées.
* `role` et `renewedOn` sont conservés tels quels (indispensable pour une
* mise à jour, qui régénère la fiche sans devoir en perdre un champ).
*/
export function toMemberYaml({ handle, name, linkedin, website, description, statusIndex, role = 'certifie', renewedOn }) {
if (!Number.isInteger(statusIndex) || statusIndex < 0) throw new RequestError('statusIndex requis');
const lines = [
`github: ${yamlScalar(handle)}`,
`role: "certifie"`,
`role: ${yamlScalar(role)}`,
`name: ${yamlScalar(name)}`,
`linkedin: ${yamlScalar(linkedin)}`,
`photo: ${yamlScalar(photoPathFor(handle))}`, // objet Git LFS (#16), normalisé à l'intake (#21)
`status_index: ${statusIndex}`, // index permanent dans la Bitstring Status List (CT-4)
];
if (website) lines.push(`website: ${yamlScalar(website)}`);
if (description) lines.push(`description: ${yamlScalar(description)}`);
if (renewedOn) lines.push(`renewed_on: ${yamlScalar(renewedOn)}`); // #27 : prime sur certified_on
return lines.join('\n') + '\n';
}

Expand Down
121 changes: 121 additions & 0 deletions .github/scripts/lib/update-request.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Construit la fiche mise à jour d'un membre à partir d'une issue de mise à jour.
// Identité = auteur de l'issue (compte GitHub), jamais un champ saisi (CT-3).
// Fonctions pures : ni lecture d'event, ni écriture de fichier.
//
// Un badge signé n'encode QUE le handle + les dates + le statut ; nom, LinkedIn,
// photo et description vivent dans l'annuaire seul. Une mise à jour ne re-signe
// donc rien : elle régénère la fiche (mêmes github/role/status_index) et l'emit
// reproduit un credential byte-identique. Un champ laissé vide = inchangé.
import { parseIssueFormBody, clean } from './issue-form.mjs';
import { extractImageUrl } from './photo-url.mjs';
import { parseMemberYaml } from './credential.mjs';
import { recordPathFor, photoPathFor } from './member-paths.mjs';
import {
HEADING, LIMIT, RequestError, omitNoResponse,
requireSingleLine, requireUnderLimit, isHttpsUrl, parseHandle, toMemberYaml,
} from './certification-request.mjs';

/** Valeur d'un champ du formulaire, vidée du placeholder « _No response_ ». */
const field = (fields, key) => omitNoResponse(clean(fields[key] ?? ''));

/** Nom : si fourni, une ligne non vide sous la limite ; sinon on garde l'existant. */
function updateName(fields, current) {
const value = field(fields, HEADING.name);
if (!value) return current;
const name = requireUnderLimit(requireSingleLine(value, 'Nom'), 'Nom', LIMIT.name);
if (!name) return current;
return name;
}

/** LinkedIn : si fourni, URL https linkedin.com sous la limite ; sinon inchangé. */
function updateLinkedin(fields, current) {
const value = field(fields, HEADING.linkedin);
if (!value) return current;
requireUnderLimit(requireSingleLine(value, 'LinkedIn'), 'LinkedIn', LIMIT.linkedin);
if (!isHttpsUrl(value) || !/linkedin\.com/i.test(value)) {
throw new RequestError('LinkedIn doit être une URL https linkedin.com');
}
return value;
}

/** Site : si fourni, URL https sous la limite ; sinon inchangé. */
function updateWebsite(fields, current) {
const value = field(fields, HEADING.website);
if (!value) return current;
requireUnderLimit(requireSingleLine(value, 'Site'), 'Site', LIMIT.website);
if (!isHttpsUrl(value)) throw new RequestError('Site doit être une URL https');
return value;
}

/** Description : si fournie, une ligne sous la limite ; sinon inchangée. */
function updateDescription(fields, current) {
const value = field(fields, HEADING.description);
if (!value) return current;
return requireUnderLimit(requireSingleLine(value, 'Description'), 'Description', LIMIT.description);
}

/**
* Transforme une issue de mise à jour + la fiche existante en fiche régénérée.
* @param {object} issue objet event.issue (auteur = membre)
* @param {string} existingYaml contenu actuel de data/members/<handle>/record.yml
* Lève RequestError si un champ fourni est invalide.
*/
export function buildMemberUpdate(issue, existingYaml) {
const handle = parseHandle(issue?.user?.login);
const current = parseMemberYaml(existingYaml);

const statusIndex = Number(current.status_index);
if (!Number.isInteger(statusIndex) || statusIndex < 0) {
throw new RequestError(`status_index invalide dans la fiche de @${handle}`);
}

const fields = parseIssueFormBody(issue?.body || '');
const before = {
name: current.name ?? '',
linkedin: current.linkedin ?? '',
website: current.website ?? '',
description: current.description ?? '',
};
const after = {
name: updateName(fields, before.name),
linkedin: updateLinkedin(fields, before.linkedin),
website: updateWebsite(fields, before.website),
description: updateDescription(fields, before.description),
};

// Photo : ne change que si une image est déposée dans le champ.
const photoUrl = extractImageUrl(field(fields, HEADING.photo)) || '';

const changed =
photoUrl !== '' ||
after.name !== before.name ||
after.linkedin !== before.linkedin ||
after.website !== before.website ||
after.description !== before.description;

const yaml = toMemberYaml({
handle,
name: after.name,
linkedin: after.linkedin,
website: after.website,
description: after.description,
statusIndex,
role: current.role || 'certifie', // préservé (niveau 2 « habilite » à venir, #8)
renewedOn: current.renewed_on, // préservé : sinon la date d'émission dériverait (#27)
});

return {
handle,
...after,
before,
changed,
statusIndex,
photoUrl,
photoChanged: photoUrl !== '',
photoPath: photoPathFor(handle),
issueNumber: issue.number,
path: recordPathFor(handle),
branch: `maj/${handle}-${issue.number}`,
yaml,
};
}
110 changes: 110 additions & 0 deletions .github/scripts/lib/update-request.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { RequestError, toMemberYaml } from './certification-request.mjs';
import { parseMemberYaml } from './credential.mjs';
import { buildMemberUpdate } from './update-request.mjs';

const PHOTO_MD = '![moi](https://github.com/user-attachments/assets/abc)';

// Fiche existante, écrite dans la forme canonique (comme l'intake la produit).
function existingRecord(overrides = {}) {
return toMemberYaml({
handle: 'jdupont',
name: 'Jean Dupont',
linkedin: 'https://www.linkedin.com/in/jd',
website: '',
description: '',
statusIndex: 5,
...overrides,
});
}

// Corps d'issue de mise à jour : chaque champ non fourni = « _No response_ » (inchangé).
function issueBody({ name, linkedin, website, description, photo } = {}) {
return [
`### Nom complet\n\n${name || '_No response_'}`,
`### Profil LinkedIn\n\n${linkedin || '_No response_'}`,
`### Site web\n\n${website || '_No response_'}`,
`### Description\n\n${description || '_No response_'}`,
`### Photo\n\n${photo || '_No response_'}`,
].join('\n\n');
}

function issue(body = {}, overrides = {}) {
return { number: 12, user: { login: 'jdupont' }, body: issueBody(body), ...overrides };
}

describe('buildMemberUpdate — champs', () => {
it('met à jour uniquement le champ fourni, garde les autres', () => {
const u = buildMemberUpdate(issue({ description: 'Nouvelle bio.' }), existingRecord());
assert.equal(u.description, 'Nouvelle bio.');
assert.equal(u.name, 'Jean Dupont'); // inchangé
assert.equal(u.linkedin, 'https://www.linkedin.com/in/jd');
assert.equal(u.changed, true);
assert.equal(u.photoChanged, false);
});

it('un formulaire entièrement vide ne change rien (changed=false)', () => {
const u = buildMemberUpdate(issue({}), existingRecord({ description: 'Bio' }));
assert.equal(u.changed, false);
assert.equal(u.name, 'Jean Dupont');
assert.equal(u.description, 'Bio');
});

it('détecte une nouvelle photo', () => {
const u = buildMemberUpdate(issue({ photo: PHOTO_MD }), existingRecord());
assert.equal(u.photoChanged, true);
assert.equal(u.photoUrl, 'https://github.com/user-attachments/assets/abc');
assert.equal(u.changed, true);
});

it('rejette un LinkedIn non https/linkedin', () => {
assert.throws(() => buildMemberUpdate(issue({ linkedin: 'http://x.com' }), existingRecord()), RequestError);
});

it('rejette un site non https', () => {
assert.throws(() => buildMemberUpdate(issue({ website: 'ftp://x' }), existingRecord()), RequestError);
});

it('expose le diff avant/après', () => {
const u = buildMemberUpdate(issue({ name: 'Jeanne Dupont' }), existingRecord());
assert.equal(u.before.name, 'Jean Dupont');
assert.equal(u.name, 'Jeanne Dupont');
});
});

describe('buildMemberUpdate — invariants de préservation', () => {
it('préserve status_index et role', () => {
const u = buildMemberUpdate(issue({ name: 'X' }), existingRecord({ role: 'habilite', statusIndex: 9 }));
assert.equal(u.statusIndex, 9);
assert.match(u.yaml, /status_index: 9/);
assert.match(u.yaml, /role: "habilite"/);
});

it('la branche est dérivée du handle + numéro d’issue', () => {
const u = buildMemberUpdate(issue({ name: 'X' }), existingRecord());
assert.equal(u.branch, 'maj/jdupont-12');
assert.equal(u.path, 'data/members/jdupont/record.yml');
});

// Trap #2 (advisor) : la régénération ne doit perdre AUCUN champ. Round-trip
// byte-identical d'une fiche complète prouve que credential et date d'émission
// (qui dépendent de handle + status_index + certified_on) restent intacts.
it('round-trip byte-identical d’une fiche complète (dont renewed_on)', () => {
const full = toMemberYaml({
handle: 'jdupont',
name: 'Jean Dupont',
linkedin: 'https://www.linkedin.com/in/jd',
website: 'https://jd.fr',
description: 'Bio complète.',
statusIndex: 5,
role: 'certifie',
renewedOn: '2027-01-01T00:00:00Z',
});
// Formulaire vide -> régénération pure de l'existant.
const u = buildMemberUpdate(issue({}), full);
assert.equal(u.yaml, full);
// Et la fiche reparse à l'identique.
assert.deepEqual(parseMemberYaml(u.yaml), parseMemberYaml(full));
});
});
Loading
Loading