Comment introduire de nouveaux outils en cours de projet?

Développement mars 31, 2021

De nos jours, la plupart des projets de développement logiciel utilisent plusieurs outils pour garantir un certain niveau de qualité du code et des systèmes. On parle ici de formateurs, de linters ou d’autres analyseurs statiques de code. Ces outils sont généralement configurés au début d’un projet par souci de simplicité.

Mais qu’arrive-t-il lorsque l’on fait la découverte d’un nouvel outil après des mois de développement? On peut l’intégrer au projet, mais il est probable que l’outil en question génère des centaines, voire des milliers d’erreurs. Régler toutes ces erreurs d’un coup représente souvent une tâche beaucoup trop grosse pour être réalisable dans un temps raisonnable; en d’autres mots, on met souvent de côté ces améliorations parce que l’obstacle initial semble insurmontable, au détriment de la santé du projet à long terme.

Et si, plutôt que d’essayer d’arranger tout le projet, on se concentrait uniquement sur les développements futurs? Dans cet article, nous explorerons une technique que j’ai utilisée à grand succès pour intégrer progressivement un nouvel outil de linting dans un projet logiciel.

Appliquer un outil de façon progressive

Une technique que j'ai utilisée récemment pour introduire un nouvel outil dans un projet consiste à l'appliquer uniquement sur les fichiers modifiés par une merge request, avec bien sûr une validation par le CI. De cette façon, on peut assurer notre standard de qualité pour tous les développements futurs, en plus de corriger rétroactivement le reste du projet au fur et à mesure qu'on y apporte des modifications.

Les étapes qui suivent détaillent une implémentation pour un projet Javascript dans lequel nous allons introduire une nouvelle règle ESLint, le tout validé par un pipeline GitLab CI. Elles devraient être facilement adaptables à la plupart des projets logiciels; les seuls prérequis sont les suivants:

  • Votre outil de choix doit offrir la possibilité de spécifier la list exacte des fichiers à valider, que ce soit par un paramètre de ligne de commande ou un fichier de configuration
  • Votre système de CI doit permettre d'obtenir la liste des fichiers modifiés par la merge request (ou pull request, selon votre dépôt).

Mise en place du projet

Pour notre exemple, la première étape est d'écrire une nouvelle configuration ESLint qui comprend notre nouvelle règle. Si vous introduisez un tout nouvel outil, il suffit de le configurer comme vous le voulez pour votre projet.

// strict.eslintrc

{
    "rules": [
      "no-empty-function": "error"
    ]
}

N.B. Tout au long de l'exemple, nous utiliserons le mot "strict" pour identifier la configuration qui contient la nouvelle règle.

Dans le cas d'ESLint, il est aussi possible d'étendre votre configuration existante si vous voulez ajouter vos nouvelles règles plutôt que de les remplacer. De cette façon, vous pourrez remplacer votre pipeline de CI existant plutôt que d'y ajouter une nouvelle étape, comme nous le verrons plus tard.

Une fois la configuration créée, il vous faudra un moyen d'identifier les fichiers sur lesquels l'appliquer. Pour favoriser l'expérience des développeurs, il est important que ceci fonctionne tant en CI que sur un poste de travail, et ce peu importe si la branche courante comporte des fichiers en cours de modification ou pas.

Pour le CI, nous utiliserons un script Javascript pour obtenir la liste des fichiers modifiés directement depuis l’API de GitLab.

N.B. Pour obtenir la liste des fichiers modifiés sur GitLab, le script doit obligatoirement s'exécuter dans un pipeline for merge request.

// getChangedFiles.js

const { spawnSync } = require('child_process')
const fetch = require('node-fetch')
const { exit } = require('process')

// Par défaut, "fetch" ne lance pas d'exception en cas d'erreur. Cette
// fonction nous permettra de chaîner les promesses un peu plus facilement.
function handleErrors(response) {
  if (!response.ok) {
    throw new Error(response.status)
  }
  return response
}

// Cette variable d'environnement est assignée automatiquement par la
// plupart des systèmes de CI
if (process.env.CI === 'true') {
  // Ces trois variables proviennent directement de GitLab CI:
  // https://docs.gitlab.com/13.6/ee/ci/variables/predefined_variables.html
  const apiURL = process.env.CI_API_V4_URL
  const projectID = process.env.CI_PROJECT_ID
  const mrIID = process.env.CI_MERGE_REQUEST_IID
  // Cette variable est assignée via l'interface de GitLab, tel qu'illustré plus bas.
  const accessToken = process.env.CI_ACCESS_TOKEN

  fetch(
    // L'option "access_raw_diffs" est importante pour éviter que GitLab ne
    // limite la taille de la réponse:
    // https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr-changes
    `${apiURL}/projects/${projectID}/merge_requests/${mrIID}/changes?access_raw_diffs`,
    {
      headers: { 'PRIVATE-TOKEN': accessToken },
    }
  )
    .then(handleErrors)
    .then((response) => response.json())
    .then((json) => {
      const changedFilePaths = json.changes
        .filter((change) => !change.deleted_file)
        .map((change) => change.new_path)
        .filter((path) => /(\.ts)|(\.tsx)$/.test(path))

      console.log(changedFilePaths.join(' '))
    })
    .catch((e) => {
      console.error(e)
      exit(1)
    })
} else {
  // Voir l'implémentation de "getLocalDiff.sh" ci-bas
  console.log(String(spawnSync('./getLocalDiff.sh').stdout))
}

Du côté des machines de travail des développeurs, un simple script bash et quelques commandes git feront amplement l'affaire:

# getLocalDiff.sh

#!/usr/bin/env bash

# Obtenir la branche courante
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
# S'assurer d'être à jour avec le serveur
git fetch
# Obtenir le commit où la branche courante et sa base ont divergé
HASH=$(git merge-base origin/develop $CURRENT_BRANCH)

# Obtenir la liste des fichiers modifiés, mais pas supprimés
MODIFIED=$(git diff --name-only --diff-filter=d $HASH -- "*.ts" "*.tsx")
# Obtenir la liste des fichiers ajoutés mais pas suivis par git (ceci permet d'obtenir le même résultat avant et après "git add")
NEW=$(git ls-files --others --exclude-standard "*.ts" "*.tsx")

# Imprimer la combinaison des deux listes et remplacer les retours de ligne par des espaces
echo "$MODIFIED $NEW" | tr '\n' ' '

Finalement, tout ce qu'il manque est un simple script npm pour exécuter cette configuration:

// package.json

"scripts": {
  // "FORCE_COLOR=true" sert simplement à indiquer à ESLint d'afficher les résultats en couleur même si l'environnement d'exécution ne le supporte pas à priori (e.g. en CI)
  "eslint:strict": "FORCE_COLOR=true eslint -c strict.eslintrc $(node getChangedFiles.js)",
}

À ce moment, vous devriez être capable d'exécuter le script npm localement pour valider les fichiers modifiés par votre branche.

Mise en place du CI

Tout d'abord, comme mentionné plus haut, il est important de s'assurer que les pipelines pour merge requests sont activés pour le projet.

Ensuite, il faudra créer une job pour exécuter notre nouveau script:

# .gitlab-ci.yaml

eslint-strict:
    stage: test
    script:
        - npm run eslint:strict
    rules:
        # Cette job ne peut être exécutée que si le pipeline courant est
        # associé à une merge request.
        - if: $CI_MERGE_REQUEST_IID

Finalement, il faudra permettre au script de s'authentifier à GitLab. Dans notre cas, nous utiliserons un token d'accès personnel ou de projet auquel la job configurée précédemment aura accès via une variable d'environnement pour runner GitLab.

Les tokens de projet sont à favoriser, mais ils ne sont actuellement disponibles que pour les GitLab "self-hosted" ou pour les équipes gitlab.com payantes. Si vous n’y avez pas accès, un token personnel peut également faire l’affaire, mais il serait important de s'assurer de changer le token si jamais la personne qui l'a créé venait à quitter l'équipe.

Il est aussi à noter que GitLab offre une méthode d’authentification par token de pipeline CI, mais cette méthode ne fonctionne que pour une liste limitée de routes API dont la route des changements de merge request ne fait pas partie au moment d'écrire cet article. Il est possible que cela change dans le futur.

Alternatives

Il est important de mentionner que cette idée n'est pas nouvelle et qu'il existe des solutions plus simples. lint-staged en est un bon exemple. Cependant, au moment d'appliquer ceci dans mon projet, je n'ai trouvé aucune solution qui s'intègre parfaitement au niveau du CI, encore moins une solution qui soit indépendante des technologies sous-jacentes (ma solution ne respecte pas non plus ce critère, étant dépendante de NodeJS pour accéder à l'API de GitLab, mais au moins le script devrait être très facile à adapter dans un autre langage, y compris bash).

Résultats

C'est bien beau tous ces scripts, mais est-ce que ça fonctionne vraiment? Excellente question!

Dans le cas de mon projet, je dirais que oui, ça a fonctionné à merveille! Nous avons utilisé cette technique pour introduire le plugin typescript-eslint dans notre projet. Au moment de l'intégration, ce plugin relevait 1933 problèmes dans notre code (erreurs et avertissements combinés). Après 4 mois, nous n'en avons plus que 745. Le plus beau, c'est que nous avons accompli cela sans jamais créer un commit exclusivement pour régler ces erreurs. Tout s'est fait à même le développement du projet, sans la moindre friction supplémentaire (outre les quelques dos d'âne rencontrés pour peaufiner les différents scripts).

Résultat d'ESLint lors de l'intégration de typescript-eslint
Résultat d'ESLint après 4 mois

Mots clés

Étienne Lévesque

Gradué de l'Université de Sherbrooke en 2020, je m'intéresse à l'informatique et la programmation depuis l'âge de 15 ans. Je trippe sur les bonnes pratiques, la prog. fonctionnelle et l'architecture.