name: Build

on:
  push:
    paths:
      - ".github/workflows/build.yml"
      - "app/**"
  pull_request:
    paths:
      - ".github/workflows/build.yml"
      - "app/**"
  schedule:
    - cron: '22 4 * * *'

jobs:
  build:
    if: ${{ always() }}
    runs-on: ubuntu-latest
    container:
      image: docker.io/zmkfirmware/zmk-build-arm:3.0
    needs: compile-matrix
    strategy:
      matrix:
        include: ${{ fromJSON(needs.compile-matrix.outputs.include-list) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Cache west modules
        uses: actions/cache@v2
        env:
          cache-name: cache-zephyr-modules
        with:
          path: |
            modules/
            tools/
            zephyr/
            bootloader/
          key: 4-${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('app/west.yml') }}
          restore-keys: |
            4-${{ runner.os }}-build-${{ env.cache-name }}-
            4-${{ runner.os }}-build-
            4-${{ runner.os }}-
        timeout-minutes: 2
        continue-on-error: true
      - name: Initialize workspace (west init)
        run: west init -l app
      - name: Update modules (west update)
        run: west update
      - name: Export Zephyr CMake package (west zephyr-export)
        run: west zephyr-export
      - name: Use Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '14.x'
      - name: Install @actions/artifact
        run: npm install @actions/artifact
      - name: Build and upload artifacts
        uses: actions/github-script@v4
        id: boards-list
        with:
          script: |
            const fs = require('fs');
            const artifact = require('@actions/artifact');
            const artifactClient = artifact.create();

            const execSync = require('child_process').execSync;
            
            const buildShieldArgs = JSON.parse(`${{ matrix.shieldArgs }}`);

            let error = false;

            for (const shieldArgs of buildShieldArgs) {
              try {
                const output = execSync(`west build -s app -p -b ${{ matrix.board }} -- ${shieldArgs.shield ? '-DSHIELD=' + shieldArgs.shield : ''} ${shieldArgs['cmake-args'] || ''}`);

                console.log(`::group::${{ matrix.board}} ${shieldArgs.shield} Build`)
                console.log(output.toString());

                const fileExtensions = ["hex", "uf2"];

                const files = fileExtensions
                  .map(extension => "build/zephyr/zmk." + extension)
                  .filter(path => fs.existsSync(path));

                const rootDirectory = 'build/zephyr';
                const options = {
                    continueOnError: true
                }

                const cmakeName = shieldArgs['cmake-args'] ? '-' + (shieldArgs.nickname || shieldArgs['cmake-args'].split(' ').join('')) : '';
                const artifactName = `${{ matrix.board }}${shieldArgs.shield ? '-' + shieldArgs.shield : ''}${cmakeName}-zmk`;

                await artifactClient.uploadArtifact(artifactName, files, rootDirectory, options);
              } catch (e) {
                console.error(`::error::Failed to build or upload ${{ matrix.board }} ${shieldArgs.shield} ${shieldArgs['cmake-args']}`);
                console.error(e);
                error = true;
              } finally {
                console.log('::endgroup::');
              }
            }

            if (error) {
              throw new Error('Failed to build one or more configurations');
            }
  compile-matrix:
    if: ${{ always() }}
    runs-on: ubuntu-latest
    needs: [core-coverage, board-changes, nightly]
    outputs:
      include-list: ${{ steps.compile-list.outputs.result }}
    steps:
      - name: Join build lists
        uses: actions/github-script@v4
        id: compile-list
        with:
          script: |
            const coreCoverage = `${{ needs.core-coverage.outputs.core-include }}` || "[]";
            const boardChanges = `${{ needs.board-changes.outputs.boards-include }}` || "[]";
            const nightly = `${{ needs.nightly.outputs.nightly-include }}` || "[]";
            
            const combined = [
              ...JSON.parse(coreCoverage),
              ...JSON.parse(boardChanges),
              ...JSON.parse(nightly)
            ];
            const combinedUnique = [...new Map(combined.map(el => [JSON.stringify(el), el])).values()];

            const perBoard = {};

            for (const configuration of combinedUnique) {
              if (!perBoard[configuration.board])
                perBoard[configuration.board] = [];
              
              perBoard[configuration.board].push({
                shield: configuration.shield,
                'cmake-args': configuration['cmake-args'],
                nickname: configuration.nickname
              })
            }

            return Object.entries(perBoard).map(([board, shieldArgs]) => ({
              board,
              shieldArgs: JSON.stringify(shieldArgs),
            }));
  core-coverage:
    if: ${{ needs.get-changed-files.outputs.core-changes == 'true' }}
    runs-on: ubuntu-latest
    needs: get-changed-files
    outputs:
      core-include: ${{ steps.core-list.outputs.result }} 
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Use Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '14.x'
      - name: Install js-yaml
        run: npm install js-yaml
      - uses: actions/github-script@v4
        id: core-list
        with:
          script: |
            const fs = require('fs');
            const yaml = require('js-yaml');

            const coreCoverage = yaml.load(fs.readFileSync('app/core-coverage.yml', 'utf8'));
            
            let include = coreCoverage.board.flatMap(board =>
              coreCoverage.shield.map(shield => ({ board, shield }))
            );

            return [...include, ...coreCoverage.include];
  board-changes:
    if: ${{ needs.get-changed-files.outputs.board-changes == 'true' }}
    runs-on: ubuntu-latest
    needs: [get-grouped-hardware, get-changed-files]
    outputs:
      boards-include: ${{ steps.boards-list.outputs.result }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Use Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '14.x'
      - name: Install js-yaml
        run: npm install js-yaml
      - uses: actions/github-script@v4
        id: boards-list
        with:
          script: |
            const fs = require('fs');
            const yaml = require('js-yaml');

            const changedFiles = JSON.parse(`${{ needs.get-changed-files.outputs.changed-files }}`);
            const metadata = JSON.parse(`${{ needs.get-grouped-hardware.outputs.organized-metadata }}`);
            const boardChanges = new Set(changedFiles.filter(f => f.startsWith('app/boards')).map(f => f.split('/').slice(0, 4).join('/')));

            return (await Promise.all([...boardChanges].flatMap(async bc => {
              const globber = await glob.create(bc + "/*.zmk.yml");
              const files = await globber.glob();

              const aggregated = files.flatMap((f) =>
                yaml.loadAll(fs.readFileSync(f, "utf8"))
              );

              const boardAndShield = (b, s) => {
                if (s.siblings) {
                  return s.siblings.map(shield => ({
                    board: b.id,
                    shield,
                  }));
                } else {
                  return {
                    board: b.id,
                    shield: s.id
                  };
                }
              }

              return aggregated.flatMap(hm => {
                switch (hm.type) {
                  case "board":
                    if (hm.features && hm.features.includes("keys")) {
                      if (hm.siblings) {
                        return hm.siblings.map(board => ({
                          board,
                        }));
                      } else {
                        return {
                          board: hm.id
                        };
                      }
                    } else if (hm.exposes) {
                      return hm.exposes.flatMap(i => 
                        metadata.interconnects[i].shields.flatMap(s => boardAndShield(hm, s))
                      );
                    } else {
                      console.error("Board without keys or interconnect");
                    }
                    break;
                  case "shield":
                    if (hm.features && hm.features.includes("keys")) {
                      return hm.requires.flatMap(i => 
                        metadata.interconnects[i].boards.flatMap(b => boardAndShield(b, hm))
                      );
                    }
                    break;
                  case "interconnect":
                    return [];
                }
              });
            }))).flat();
  nightly:
    if: ${{ github.event_name == 'schedule' }}
    runs-on: ubuntu-latest
    needs: get-grouped-hardware
    outputs:
      nightly-include: ${{ steps.nightly-list.outputs.result }}
    steps:
      - name: Create nightly list
        uses: actions/github-script@v4
        id: nightly-list
        with:
          script: |
            const metadata = JSON.parse(`${{ needs.get-grouped-hardware.outputs.organized-metadata }}`);
            
            let includeOnboard = metadata.onboard.flatMap(b => {
              if (b.siblings) {
                return b.siblings.map(board => ({
                  board,
                }));
              } else {
                return {
                  board: b.id,
                };
              }
            });

            let includeInterconnect = Object.values(metadata.interconnects).flatMap(i =>
              i.boards.flatMap(b =>
                i.shields.flatMap(s => {
                  if (s.siblings) {
                    return s.siblings.map(shield => ({
                      board: b.id,
                      shield,
                    }));
                  } else {
                    return {
                      board: b.id,
                      shield: s.id,
                    };
                  }
                })
              )
            );

            return [...includeOnboard, ...includeInterconnect];
  get-grouped-hardware:
    runs-on: ubuntu-latest
    outputs:
      organized-metadata: ${{ steps.organize-metadata.outputs.result }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Use Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '14.x'
      - name: Install js-yaml
        run: npm install js-yaml
      - name: Aggregate Metadata
        uses: actions/github-script@v4
        id: aggregate-metadata
        with:
          script: |
            const fs = require('fs');
            const yaml = require('js-yaml');

            const globber = await glob.create("app/boards/**/*.zmk.yml");
            const files = await globber.glob();

            const aggregated = files.flatMap((f) =>
              yaml.loadAll(fs.readFileSync(f, "utf8"))
            );

            return JSON.stringify(aggregated).replace(/\\/g,"\\\\");
          result-encoding: string

      - name: Organize Metadata
        uses: actions/github-script@v4
        id: organize-metadata
        with:
          script: |
            const hardware = JSON.parse(`${{ steps.aggregate-metadata.outputs.result }}`);

            const grouped = hardware.reduce((agg, hm) => {
              switch (hm.type) {
                case "board":
                  if (hm.features && hm.features.includes("keys")) {
                    agg.onboard.push(hm);
                  } else if (hm.exposes) {
                    hm.exposes.forEach((element) => {
                      let ic = agg.interconnects[element] || {
                        boards: [],
                        shields: [],
                      };
                      ic.boards.push(hm);
                      agg.interconnects[element] = ic;
                    });
                  } else {
                    console.error("Board without keys or interconnect");
                  }
                  break;
                case "shield":
                  if (hm.features && hm.features.includes("keys")) {
                    hm.requires.forEach((id) => {
                      let ic = agg.interconnects[id] || { boards: [], shields: [] };
                      ic.shields.push(hm);
                      agg.interconnects[id] = ic;
                    });
                  }
                  break;
                case "interconnect":
                  let ic = agg.interconnects[hm.id] || { boards: [], shields: [] };
                  ic.interconnect = hm;
                  agg.interconnects[hm.id] = ic;
                  break;
              }
              return agg;
            },
            { onboard: [], interconnects: {} });

            return JSON.stringify(grouped).replace(/\\/g,"\\\\");
          result-encoding: string
  get-changed-files:
    if: ${{ github.event_name != 'schedule' }}
    runs-on: ubuntu-latest
    outputs:
      changed-files: ${{ steps.changed-files.outputs.all }}
      board-changes: ${{ steps.board-changes.outputs.result }}
      core-changes: ${{ steps.core-changes.outputs.result }}
    steps:
      - uses: Ana06/get-changed-files@v2.0.0
        id: changed-files
        with:
          format: 'json'
      - uses: actions/github-script@v4
        id: board-changes
        with:
          script: |
            const changedFiles = JSON.parse(`${{ steps.changed-files.outputs.all }}`);
            const boardChanges = changedFiles.filter(f => f.startsWith('app/boards'));
            return boardChanges.length ? 'true' : 'false';
          result-encoding: string
      - uses: actions/github-script@v4
        id: core-changes
        with:
          script: |
            const changedFiles = JSON.parse(`${{ steps.changed-files.outputs.all }}`);
            const boardChanges = changedFiles.filter(f => f.startsWith('app/boards'));
            const appChanges = changedFiles.filter(f => f.startsWith('app'));
            const ymlChanges = changedFiles.includes('.github/workflows/build.yml');
            return boardChanges.length < appChanges.length || ymlChanges ? 'true' : 'false';
          result-encoding: string
