Repository: pyccel/pyccel (language: python)

Issue:
add the classes.py file to codegen

merge



Discussion / hints:

Fix patch:
diff --git a/.docstr_coverage b/.docstr_coverage
new file mode 100644
index 0000000000..8007a6bb25
--- /dev/null
+++ b/.docstr_coverage
@@ -0,0 +1,3 @@
+.*code _print_.*
+semantic _visit_.*
+syntactic _visit_.*
diff --git a/.github/actions/coverage_collection/action.yml b/.github/actions/coverage_collection/action.yml
index 03d1b2cdfd..0e665f7568 100644
--- a/.github/actions/coverage_collection/action.yml
+++ b/.github/actions/coverage_collection/action.yml
@@ -1,4 +1,9 @@
 name: 'Coverage Collection'
+inputs:
+  shell_cmd:
+    description: 'Specifies the shell command (different for anaconda)'
+    required: false
+    default: 'bash'
 
 runs:
   using: "composite"
@@ -6,7 +11,6 @@ runs:
     - name: Coverage collection
       run: |
         coverage combine
-        coverage xml
         rm ${SITE_DIR}/pyccel_cov.pth
-      shell: bash
+      shell: ${{ inputs.shell_cmd }}
 
diff --git a/.github/actions/coverage_install/action.yml b/.github/actions/coverage_install/action.yml
index 44ef760dcb..ac5294e542 100644
--- a/.github/actions/coverage_install/action.yml
+++ b/.github/actions/coverage_install/action.yml
@@ -1,4 +1,9 @@
 name: 'Coverage Setup'
+inputs:
+  shell_cmd:
+    description: 'Specifies the shell command (different for anaconda)'
+    required: false
+    default: 'bash'
 
 runs:
   using: "composite"
@@ -6,7 +11,7 @@ runs:
     - name: Installation
       run: |
         python -m pip install coverage
-      shell: bash
+      shell: ${{ inputs.shell_cmd }}
     - name: Directory Creation
       run: |
         INSTALL_DIR=$(cd tests; python -c "import pyccel; print(pyccel.__path__[0])")
@@ -15,4 +20,4 @@ runs:
         echo -e "[run]\nparallel = True\nsource = ${INSTALL_DIR}\ndata_file = $(pwd)/.coverage\n[report]\ninclude = ${INSTALL_DIR}/*\n[xml]\noutput = cobertura.xml" > .coveragerc
         echo "SITE_DIR=${SITE_DIR}" >> $GITHUB_ENV
         echo "COVERAGE_PROCESS_START=$(pwd)/.coveragerc" >> $GITHUB_ENV
-      shell: bash
+      shell: ${{ inputs.shell_cmd }}
diff --git a/.github/actions/generate_bot_token/action.yml b/.github/actions/generate_bot_token/action.yml
new file mode 100644
index 0000000000..987322af27
--- /dev/null
+++ b/.github/actions/generate_bot_token/action.yml
@@ -0,0 +1,23 @@
+name: "Generate bot token"
+inputs:
+  bot_pem:
+    description: "The private key which identifies the bot"
+    required: true
+  dir:
+    description: "The directory where the code is found"
+    required: false
+    default: "."
+
+runs:
+  using: "composite"
+  steps:
+    - run: |
+        pip install jwt requests
+      shell: bash
+    - run: |
+        cd ${{ inputs.dir }}
+        python -c "from ci_tools.bot_tools.github_api_interactions import get_authorization; t,d=get_authorization(); print('token=',t,sep=''); print('token_exp=',d,sep='')"
+      shell: bash
+      id: get_token
+      env:
+        PEM: ${{ inputs.bot_pem }}
diff --git a/.github/actions/post_completed/action.yml b/.github/actions/post_completed/action.yml
new file mode 100644
index 0000000000..08e0ecc74a
--- /dev/null
+++ b/.github/actions/post_completed/action.yml
@@ -0,0 +1,34 @@
+name: 'Posting in progress status'
+inputs:
+  installation_token:
+    description: "The GitHub API token of the bot"
+    required: true
+  run_id:
+    description: "The id of the check run"
+    required: true
+  status:
+    description: "The final status of the workflow"
+    required: true
+
+runs:
+  using: "composite"
+  steps:
+    - name: "Collecting status"
+      run: |
+        import os
+        import json
+        import requests
+        if os.path.exists('test_json_result.json'):
+            with open('test_json_result.json', 'r') as f:
+                result = json.load(f)
+        else:
+            result = {}
+        result["status"] = "completed"
+        result["conclusion"] = "${{ inputs.status }}"
+
+        headers = {"Accept": "application/vnd.github+json", "Authorization": "Bearer ${{ inputs.installation_token }}", "X-GitHub-Api-Version": "2022-11-28"}
+
+        reply = requests.patch("https://api.github.com/repos/${{ github.repository }}/check-runs/${{ inputs.run_id }}", headers=headers, json=result)
+
+        print(reply.text)
+      shell: python
diff --git a/.github/actions/post_triggered/action.yml b/.github/actions/post_triggered/action.yml
new file mode 100644
index 0000000000..12965f53f1
--- /dev/null
+++ b/.github/actions/post_triggered/action.yml
@@ -0,0 +1,39 @@
+name: 'Posting in progress status'
+inputs:
+  ref:
+    description: "The ref to post the status to"
+    required: true
+  installation_token:
+    description: "The GitHub API token of the bot"
+    required: true
+  name:
+    description: "The name of the workflow"
+    required: true
+outputs:
+  check_id:
+    description: "The ID of the check run"
+    value: ${{ steps.post.outputs.check_id }}
+
+runs:
+  using: "composite"
+  steps:
+    - name: "Posting"
+      run: |
+        import requests
+        
+        headers = {"Accept": "application/vnd.github+json", "Authorization": "Bearer ${{ inputs.installation_token }}", "X-GitHub-Api-Version": "2022-11-28"}
+        data = {"name":"${{ inputs.name }}",
+                "head_sha":"${{ inputs.ref }}",
+                "status":"in_progress",
+                "details_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"}
+
+        reply = requests.post("https://api.github.com/repos/${{ github.repository }}/check-runs", headers=headers, json=data)
+
+        print(reply.text)
+        print(reply.json())
+
+        id = reply.json()["id"]
+        with open("${{ github.output }}", 'a') as f:
+            print("check_id=", id, sep='', file=f)
+      shell: python
+      id: post
diff --git a/.github/workflows/anaconda_linux.yml b/.github/workflows/anaconda_linux.yml
index 718a1a2420..385c588dbf 100644
--- a/.github/workflows/anaconda_linux.yml
+++ b/.github/workflows/anaconda_linux.yml
@@ -1,7 +1,7 @@
 name: Anaconda-Linux
 
 on:
-  workflow_call:
+  workflow_dispatch:
     inputs:
       python_version:
         required: true
@@ -9,6 +9,19 @@ on:
       ref:
         required: false
         type: string
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
 
 jobs:
   Anaconda-Linux:
@@ -18,34 +31,58 @@ jobs:
     steps:
       - uses: actions/checkout@v3
         with:
-          ref: ${{ inputs.ref }}
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
       - name: Install dependencies
         uses: ./.github/actions/linux_install
-      - uses: conda-incubator/setup-miniconda@v2
-        with:
-          auto-update-conda: true
-          auto-activate-base: true
-          python-version: '3.10'
-        if: inputs.python_version == ''
       - uses: conda-incubator/setup-miniconda@v2
         with:
           auto-update-conda: true
           auto-activate-base: true
           python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
       - name: Install python dependencies
         uses: ./.github/actions/conda_installation
         with:
           mpi_type: openmpi
+      - name: Coverage install
+        uses: ./.github/actions/coverage_install
+        with:
+          shell_cmd: "bash -l {0}"
       - name: Fortran/C tests with pytest
+        id: f_c_pytest
+        timeout-minutes: 60
         uses: ./.github/actions/pytest_run
         with:
           shell_cmd: "bash -l {0}"
       - name: Python tests with pytest
+        id: python_pytest
+        timeout-minutes: 20
         uses: ./.github/actions/pytest_run_python
         with:
           shell_cmd: "bash -l {0}"
       - name: Parallel tests with pytest
+        id: parallel
+        timeout-minutes: 20
         uses: ./.github/actions/pytest_parallel
         with:
           shell_cmd: "bash -l {0}"
+      - name: Collect coverage information
+        continue-on-error: True
+        uses: ./.github/actions/coverage_collection
+        with:
+          shell_cmd: "bash -l {0}"
+      - name: Save code coverage report
+        uses: actions/upload-artifact@v3
+        with:
+          name: coverage-artifact
+          path: .coverage
+          retention-days: 1
+      - name: "Post completed"
+        if: always()
+        run:
+          python ci_tools/complete_check_run.py ${{ steps.f_c_pytest.outcome }} ${{ steps.python_pytest.outcome }} ${{ steps.parallel.outcome }}
diff --git a/.github/workflows/anaconda_windows.yml b/.github/workflows/anaconda_windows.yml
index 20b178922f..2d50a69a15 100644
--- a/.github/workflows/anaconda_windows.yml
+++ b/.github/workflows/anaconda_windows.yml
@@ -1,7 +1,7 @@
 name: Anaconda-Windows
 
 on:
-  workflow_call:
+  workflow_dispatch:
     inputs:
       python_version:
         required: true
@@ -9,6 +9,19 @@ on:
       ref:
         required: false
         type: string
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
 
 jobs:
   Anaconda-Windows:
@@ -18,19 +31,18 @@ jobs:
     steps:
       - uses: actions/checkout@v3
         with:
-          ref: ${{ inputs.ref }}
-      - uses: conda-incubator/setup-miniconda@v2
-        with:
-          auto-update-conda: true
-          auto-activate-base: true
-          python-version: '3.10'
-        if: inputs.python_version == ''
+          ref: ${COMMIT}
+          repository: ${{ env.PR_REPO }}
       - uses: conda-incubator/setup-miniconda@v2
         with:
           auto-update-conda: true
           auto-activate-base: true
           python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
       - name: Install dependencies
         uses: ./.github/actions/windows_install
       - name: Install python dependencies
@@ -38,12 +50,19 @@ jobs:
         with:
           mpi_type: msmpi
       - name: Fortran/C tests with pytest
+        id: f_c_pytest
+        timeout-minutes: 60
         uses: ./.github/actions/pytest_run
         with:
           shell_cmd: 'bash -l -eo pipefail {0}'
           pytest_mark: 'and not external'
       - name: Python tests with pytest
+        id: python_pytest
+        timeout-minutes: 20
         uses: ./.github/actions/pytest_run_python
         with:
           shell_cmd: 'bash -l -eo pipefail {0}'
-
+      - name: "Post completed"
+        if: always()
+        run:
+          python ci_tools/complete_check_run.py ${{ steps.f_c_pytest.outcome }} ${{ steps.python_pytest.outcome }}
diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml
index 571cd2ab77..df9cadef55 100644
--- a/.github/workflows/bench.yml
+++ b/.github/workflows/bench.yml
@@ -2,13 +2,14 @@ name: Benchmarks
 
 on:
   push:
-    branches: [ pyccel/devel ]
+    branches: devel
 
 jobs:
 
   Benchmark:
 
     runs-on: ubuntu-latest
+    if: github.repository == 'pyccel/pyccel'
 
     steps:
       - name: Set up ssh
diff --git a/.github/workflows/clean_up.yml b/.github/workflows/clean_up.yml
new file mode 100644
index 0000000000..bd9d83c9fb
--- /dev/null
+++ b/.github/workflows/clean_up.yml
@@ -0,0 +1,21 @@
+name: Clean up
+
+on:
+  check_run:
+    types: completed
+
+jobs:
+  Post:
+    runs-on: ubuntu-latest
+    name: Report result
+
+    steps:
+      - uses: actions/checkout@v3
+      - name: Clean up
+        run: |
+          pip install jwt requests
+          python ci_tools/bot_clean_up.py
+        shell: bash
+        env:
+          PEM: ${{ secrets.BOT_PEM }}
+          GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/comment_bot.yml b/.github/workflows/comment_bot.yml
new file mode 100644
index 0000000000..2120230805
--- /dev/null
+++ b/.github/workflows/comment_bot.yml
@@ -0,0 +1,29 @@
+name: Bot comment handler
+
+on:
+  issue_comment:
+    types: [ created ]
+
+jobs:
+  Bot:
+    if: ${{ startsWith( github.event.comment.body, '/bot ' ) }}
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v4
+        with:
+          python-version: 3.9
+      - name: Install python dependencies
+        run: |
+          python -m pip install requests
+      - name: "Generate token"
+        id: token
+        uses: ./.github/actions/generate_bot_token
+        with:
+          bot_pem: "${{ secrets.BOT_PEM }}"
+      - id: run_bot
+        run: |
+          python ci_tools/bot_comment_react.py
+        env:
+          GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index e27379f0cd..ae162eef52 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -1,7 +1,7 @@
 name: Unit test coverage
 
 on:
-  workflow_call:
+  workflow_dispatch:
     inputs:
       python_version:
         required: true
@@ -12,10 +12,27 @@ on:
       base:
         required: true
         type: string
-      sha:
+      check_run_id:
+        required: false
+        type: string
+      artifact_urls:
+        required: true
+        type: string
+      pr_id:
         required: true
+        type: id
+      pr_repo:
+        required: false
         type: string
 
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_ID: ${{ inputs.pr_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
+
 jobs:
   CoverageChecker:
 
@@ -25,32 +42,46 @@ jobs:
     steps:
       - uses: actions/checkout@v3
         with:
-          ref: ${{ inputs.ref }}
-      - name: Set up Python 3.7
-        uses: actions/setup-python@v4
-        with:
-          python-version: 3.7
-        if: inputs.python_version == ''
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
       - name: Set up Python ${{ inputs.python_version }}
         uses: actions/setup-python@v4
         with:
           python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
+        shell: bash
       - name: Install Python dependencies
         run: |
           python -m pip install --upgrade pip
           python -m pip install defusedxml
+          python -m pip install coverage
         shell: bash
       - name: Collect coverage information
-        uses: actions/download-artifact@v3
+        run: |
+          python ci_tools/fetch_artifacts.py ${{ inputs.artifact_urls }}
+          echo -e "[paths]\nsource =\n   $(pwd)/pyccel\n   */site-packages/pyccel\n[xml]\noutput = cobertura.xml" > .coveragerc
+          coverage combine
+          coverage xml
+        shell: bash
+      - name: Run codacy-coverage-reporter
+        uses: codacy/codacy-coverage-reporter-action@master
+        continue-on-error: True
         with:
-          name: coverage-artifact
+          project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
+          coverage-reports: cobertura.xml
       - name: Collect diff information
         run: |
           git fetch
-          git diff origin/${{ inputs.base }}..HEAD --no-indent-heuristic --unified=0 --output=pull_diff.txt --no-color
+          git checkout ${{ inputs.base }} # Make sure there is a local copy in case of a branch
+          git checkout ${{ env.COMMIT }}
+          git diff ${{ inputs.base }}..HEAD --no-indent-heuristic --unified=0 --output=pull_diff.txt --no-color
         shell: bash
       - name: Check coverage
+        id: coverage
         run: |
-          python ci_tools/check_new_coverage.py pull_diff.txt cobertura.xml ${{ inputs.sha }} $GITHUB_STEP_SUMMARY
+          python ci_tools/check_new_coverage.py pull_diff.txt cobertura.xml $GITHUB_STEP_SUMMARY
         shell: bash
diff --git a/.github/workflows/devel.yml b/.github/workflows/devel.yml
index 3de8f8156b..1cd46f8da7 100644
--- a/.github/workflows/devel.yml
+++ b/.github/workflows/devel.yml
@@ -2,7 +2,7 @@ name: devel_tests
 
 on:
   push:
-    branches: [ devel ]
+    branches: [ pyccel/devel ]
 
 jobs:
 
diff --git a/.github/workflows/doc_coverage.yml b/.github/workflows/doc_coverage.yml
deleted file mode 100644
index 4aa6b380aa..0000000000
--- a/.github/workflows/doc_coverage.yml
+++ /dev/null
@@ -1,66 +0,0 @@
-name: Doc Coverage Action
-
-on:
-  workflow_call:
-    inputs:
-      python_version:
-        required: true
-        type: string
-      ref:
-        required: false
-        type: string
-      base:
-        required: true
-        type: string
-
-jobs:
-
-  DocumentationChecker:
-
-    runs-on: ubuntu-latest
-    name: Documentation Format
-
-    steps:
-      - uses: actions/checkout@v3
-        with:
-          ref: ${{ inputs.base }}
-          path: base
-      - uses: actions/checkout@v3
-        with:
-          path: compare
-          ref: ${{ inputs.ref }}
-      - name: Set up Python 3.8
-        uses: actions/setup-python@v4
-        with:
-          python-version: 3.8
-        if: inputs.python_version == ''
-      - name: Set up Python ${{ inputs.python_version }}
-        uses: actions/setup-python@v4
-        with:
-          python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
-      - name: Install python dependencies
-        run: |
-          python -m pip install --upgrade pip
-          python -m pip install docstr-coverage
-          python -m pip install numpydoc
-        shell: bash
-      - name: Show report
-        run: |
-          echo "${{ github.event.pull_request.base.sha }}"
-          docstr-coverage --skip-magic --skip-init base/pyccel 2>base_cov || true
-          docstr-coverage --skip-magic --skip-init compare/pyccel 2>compare_cov || true
-          python compare/ci_tools/summarise_doccoverage.py compare_cov base_cov $GITHUB_STEP_SUMMARY
-          cd compare
-          pip install .
-          git fetch
-          git diff origin/${{ inputs.base }}..HEAD --no-indent-heuristic --unified=0 --output=pull_diff.txt --no-color &>/dev/null
-          touch objects.txt
-          touch report.txt
-          python ci_tools/list_docs_tovalidate.py pull_diff.txt objects.txt
-          while read line; do    
-            python -m numpydoc --validate $line >> report.txt 2>&1 || true 
-          done < objects.txt
-          cat report.txt
-          python ci_tools/process_results.py report.txt $GITHUB_STEP_SUMMARY
-        shell: bash
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000000..134bc0406b
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,99 @@
+name: Doc Coverage Action
+
+on:
+  workflow_dispatch:
+    inputs:
+      python_version:
+        required: true
+        type: string
+      ref:
+        required: false
+        type: string
+      base:
+        required: true
+        type: string
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
+
+jobs:
+
+  DocumentationChecker:
+
+    runs-on: ubuntu-latest
+    name: Documentation Format
+
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          ref: ${{ inputs.base }}
+          path: base
+      - uses: actions/checkout@v3
+        with:
+          path: compare
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
+      - name: Set up Python ${{ inputs.python_version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ inputs.python_version }}
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python base/ci_tools/setup_check_run.py
+      - name: Install python dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install docstr-coverage
+          python -m pip install numpydoc
+        shell: bash
+      - name: Setup imports
+        run: |
+          echo "PYTHONPATH=$(pwd)/compare" >> $GITHUB_ENV
+          cd compare
+          pip3 install .
+          cd ..
+        shell: bash
+      - name: Check doc coverage
+        id: doc_coverage
+        run: |
+          echo "${{ github.event.pull_request.base.sha }}"
+          docstr-coverage --skip-magic --skip-init --fail-under=0 --docstr-ignore-file=base/.docstr_coverage base/pyccel base/ci_tools 2>&1 | tee base_cov
+          docstr-coverage --skip-magic --skip-init --fail-under=0 --docstr-ignore-file=compare/.docstr_coverage compare/pyccel compare/ci_tools 2>&1 | tee compare_cov
+          python compare/ci_tools/summarise_doccoverage.py compare_cov base_cov $GITHUB_STEP_SUMMARY
+        shell: bash
+      - name: Check doc format
+        id: doc_format
+        run: |
+          cd compare
+          git fetch
+          git diff ${{ inputs.base }}..HEAD --no-indent-heuristic --unified=0 --output=pull_diff.txt --no-color
+          python ../base/ci_tools/list_docs_tovalidate.py pull_diff.txt objects.txt ci_objects.txt
+          touch report.txt
+          while read line; do
+            python -m numpydoc --validate $line >> report.txt 2>&1 || true 
+          done < objects.txt
+          cd ci_tools
+          while read line; do
+            python -m numpydoc --validate $line >> ../report.txt 2>&1 || true 
+          done < ../ci_objects.txt
+          cd ..
+          cat report.txt
+          cd ..
+          python compare/ci_tools/process_results.py compare/report.txt $GITHUB_STEP_SUMMARY
+        shell: bash
+      - name: "Post completed"
+        if: always()
+        run:
+          python base/ci_tools/complete_check_run.py ${{ steps.doc_coverage.outcome }} ${{ steps.doc_format.outcome }}
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
deleted file mode 100644
index 0d3a3a78aa..0000000000
--- a/.github/workflows/lint.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-name: Python Linting
-
-on:
-  workflow_call:
-    inputs:
-      python_version:
-        required: true
-        type: string
-      ref:
-        required: false
-        type: string
-
-jobs:
-  Linter:
-    runs-on: ubuntu-latest
-    name: Python best practices
-
-    steps:
-      - uses: actions/checkout@v3
-        with:
-          ref: ${{ inputs.ref }}
-      - name: Set up Python 3.8
-        uses: actions/setup-python@v4
-        with:
-          python-version: 3.8
-        if: inputs.python_version == ''
-      - name: Set up Python ${{ inputs.python_version }}
-        uses: actions/setup-python@v4
-        with:
-          python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
-      - name: Check branch
-        run: |
-          git branch
-          git status
-      - name: Install python dependencies
-        run: |
-          python -m pip install --upgrade pip
-          python -m pip install pylint
-        shell: bash
-      - name: Pylint
-        run: |
-            python -m pylint --rcfile=.pylintrc pyccel/parser/semantic.py > $GITHUB_STEP_SUMMARY
-        shell: bash
diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml
index d2367d95b2..b88ab20423 100644
--- a/.github/workflows/linux.yml
+++ b/.github/workflows/linux.yml
@@ -1,7 +1,7 @@
 name: Linux unit tests
 
 on:
-  workflow_call:
+  workflow_dispatch:
     inputs:
       python_version:
         required: true
@@ -9,9 +9,19 @@ on:
       ref:
         required: false
         type: string
-    secrets:
-      CODACY_PROJECT_TOKEN:
-        required: true
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
 
 jobs:
   Linux:
@@ -22,46 +32,51 @@ jobs:
     steps:
       - uses: actions/checkout@v3
         with:
-          ref: ${{ inputs.ref }}
-      - name: Set up Python 3.7
-        uses: actions/setup-python@v4
-        with:
-          python-version: 3.7
-        if: inputs.python_version == ''
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
       - name: Set up Python ${{ inputs.python_version }}
         uses: actions/setup-python@v4
         with:
           python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
       - name: Install dependencies
         uses: ./.github/actions/linux_install
       - name: Install Pyccel with tests
         run: |
-            python -m pip install --upgrade pip
-            python -m pip install .[test]
+          python -m pip install --upgrade pip
+          python -m pip install .[test]
         shell: bash
       - name: Coverage install
         uses: ./.github/actions/coverage_install
       - name: Fortran/C tests with pytest
+        id: f_c_pytest
+        timeout-minutes: 60
         uses: ./.github/actions/pytest_run
       - name: Python tests with pytest
+        id: python_pytest
+        timeout-minutes: 20
         uses: ./.github/actions/pytest_run_python
       - name: Parallel tests with pytest
+        id: parallel
+        timeout-minutes: 20
         uses: ./.github/actions/pytest_parallel
       - name: Test with valgrind for memory leaks
+        id: valgrind
         uses: ./.github/actions/valgrind_run
       - name: Collect coverage information
         continue-on-error: True
         uses: ./.github/actions/coverage_collection
-      - name: Run codacy-coverage-reporter
-        uses: codacy/codacy-coverage-reporter-action@master
-        continue-on-error: True
-        with:
-          project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
-          coverage-reports: cobertura.xml
       - name: Save code coverage report
         uses: actions/upload-artifact@v3
         with:
           name: coverage-artifact
-          path: cobertura.xml
+          path: .coverage
           retention-days: 1
+      - name: "Post completed"
+        if: always()
+        run:
+          python ci_tools/complete_check_run.py ${{ steps.f_c_pytest.outcome }} ${{ steps.python_pytest.outcome }} ${{ steps.parallel.outcome }} ${{ steps.valgrind.outcome }}
diff --git a/.github/workflows/macosx.yml b/.github/workflows/macosx.yml
index ed26451c72..87fba74856 100644
--- a/.github/workflows/macosx.yml
+++ b/.github/workflows/macosx.yml
@@ -1,7 +1,7 @@
 name: MacOSX unit tests
 
 on:
-  workflow_call:
+  workflow_dispatch:
     inputs:
       python_version:
         required: true
@@ -9,6 +9,19 @@ on:
       ref:
         required: false
         type: string
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
 
 jobs:
   MacOSX:
@@ -19,17 +32,17 @@ jobs:
     steps:
       - uses: actions/checkout@v3
         with:
-          ref: ${{ inputs.ref }}
-      - name: Set up Python 3.10
-        uses: actions/setup-python@v4
-        with:
-          python-version: '3.10'
-        if: inputs.python_version == ''
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
       - name: Set up Python ${{ inputs.python_version }}
         uses: actions/setup-python@v4
         with:
           python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
       - name: Install dependencies
         uses: ./.github/actions/macos_install
       - name: Install Pyccel with tests
@@ -38,8 +51,18 @@ jobs:
             python -m pip install .[test]
         shell: bash
       - name: Fortran/C tests with pytest
+        id: f_c_pytest
+        timeout-minutes: 60
         uses: ./.github/actions/pytest_run
       - name: Python tests with pytest
+        id: python_pytest
+        timeout-minutes: 20
         uses: ./.github/actions/pytest_run_python
       - name: Parallel tests with pytest
+        id: parallel
+        timeout-minutes: 20
         uses: ./.github/actions/pytest_parallel
+      - name: "Post completed"
+        if: always()
+        run:
+          python ci_tools/complete_check_run.py ${{ steps.f_c_pytest.outcome }} ${{ steps.python_pytest.outcome }} ${{ steps.parallel.outcome }}
diff --git a/.github/workflows/pickle.yml b/.github/workflows/pickle.yml
index f258af1bff..6cd7d578c1 100644
--- a/.github/workflows/pickle.yml
+++ b/.github/workflows/pickle.yml
@@ -1,7 +1,7 @@
 name: Pickled-installation
 
 on:
-  workflow_call:
+  workflow_dispatch:
     inputs:
       python_version:
         required: true
@@ -12,6 +12,19 @@ on:
       editable_string:
         required: false
         type: string
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
 
 jobs:
   Pickled-installation:
@@ -23,24 +36,31 @@ jobs:
           echo "${{ inputs.python-version }}"
       - uses: actions/checkout@v3
         with:
-          ref: ${{ inputs.ref }}
-      - name: Set up Python 3.7
-        uses: actions/setup-python@v4
-        with:
-          python-version: 3.7
-        if: inputs.python_version == ''
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
       - name: Set up Python ${{ inputs.python_version }}
         uses: actions/setup-python@v4
         with:
           python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
       - name: Install dependencies
         uses: ./.github/actions/linux_install
       - name: Install Pyccel
+        id: pickle
         run: |
           python -m pip install --upgrade pip
           echo "python -m pip install ${{ inputs.editable_string }} ."
           python -m pip install ${{ inputs.editable_string }} .
         shell: bash
       - name: Check for existence of pickled files
+        id: pickle_check
         uses: ./.github/actions/check_for_pickled
+      - name: "Post completed"
+        if: always()
+        run: |
+          python ci_tools/basic_json_check_output.py --statuses ${{ steps.pickle.outcome }} ${{ steps.pickle_check.outcome }} --reasons "Installation failed." "Pickled files were not found in installaion."
+          python ci_tools/complete_check_run.py ${{ steps.pickle.outcome }} ${{ steps.pickle_check.outcome }}
diff --git a/.github/workflows/pickle_wheel.yml b/.github/workflows/pickle_wheel.yml
index 7b59ade020..4c1ab2c28c 100644
--- a/.github/workflows/pickle_wheel.yml
+++ b/.github/workflows/pickle_wheel.yml
@@ -1,7 +1,7 @@
 name: Wheel-pickled-installation
 
 on:
-  workflow_call:
+  workflow_dispatch:
     inputs:
       python_version:
         required: true
@@ -9,6 +9,19 @@ on:
       ref:
         required: false
         type: string
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
 
 jobs:
   Wheel-pickled-installation:
@@ -18,20 +31,21 @@ jobs:
     steps:
       - uses: actions/checkout@v3
         with:
-          ref: ${{ inputs.ref }}
-      - name: Set up Python 3.7
-        uses: actions/setup-python@v4
-        with:
-          python-version: 3.7
-        if: inputs.python_version == ''
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
       - name: Set up Python ${{ inputs.python_version }}
         uses: actions/setup-python@v4
         with:
           python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
       - name: Install dependencies
         uses: ./.github/actions/linux_install
       - name: Install Pyccel from wheel
+        id: pickle
         run: |
           python -m pip install --upgrade pip
           python -m pip install --upgrade build
@@ -39,4 +53,10 @@ jobs:
           python -m pip install dist/*.whl
         shell: bash
       - name: Check for existence of pickled files
+        id: pickle_check
         uses: ./.github/actions/check_for_pickled
+      - name: "Post completed"
+        if: always()
+        run: |
+          python ci_tools/basic_json_check_output.py --statuses ${{ steps.pickle.outcome }} ${{ steps.pickle_check.outcome }} --reasons "Installation failed." "Pickled files were not found in installaion."
+          python ci_tools/complete_check_run.py ${{ steps.pickle.outcome }} ${{ steps.pickle_check.outcome }}
diff --git a/.github/workflows/prepare_review_stage.yml b/.github/workflows/prepare_review_stage.yml
new file mode 100644
index 0000000000..282f53bc3a
--- /dev/null
+++ b/.github/workflows/prepare_review_stage.yml
@@ -0,0 +1,31 @@
+name: Bot
+
+on:
+  pull_request_target:
+    branches: [ devel ]
+    types: [ ready_for_review ]
+
+jobs:
+  Bot:
+    name: Preparing Pull Request for review stage
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v4
+        with:
+          python-version: 3.9
+      - name: Install python dependencies
+        run: |
+          python -m pip install requests
+      - name: "Generate token"
+        id: token
+        uses: ./.github/actions/generate_bot_token
+        with:
+          bot_pem: "${{ secrets.BOT_PEM }}"
+      - id: run_bot
+        run: |
+          python ci_tools/bot_ready_for_review.py
+        env:
+          GH_TOKEN: ${{ github.token }}
+
diff --git a/.github/workflows/pyccel_lint.yml b/.github/workflows/pyccel_lint.yml
index 0f791064d7..38bbc421b3 100644
--- a/.github/workflows/pyccel_lint.yml
+++ b/.github/workflows/pyccel_lint.yml
@@ -1,7 +1,7 @@
 name: Pyccel Linting
 
 on:
-  workflow_call:
+  workflow_dispatch:
     inputs:
       python_version:
         required: true
@@ -12,6 +12,19 @@ on:
       base:
         required: true
         type: string
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
 
 jobs:
   Pyccel-Linter:
@@ -21,17 +34,17 @@ jobs:
     steps:
       - uses: actions/checkout@v3
         with:
-          ref: ${{ inputs.ref }}
-      - name: Set up Python 3.8
-        uses: actions/setup-python@v4
-        with:
-          python-version: 3.8
-        if: inputs.python_version == ''
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
       - name: Set up Python ${{ inputs.python_version }}
         uses: actions/setup-python@v4
         with:
           python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
       - name: Install dependencies
         uses: ./.github/actions/linux_install
       - name: Install Pyccel
@@ -43,11 +56,16 @@ jobs:
       - name: Collect diff information
         run: |
           git fetch
-          git diff origin/${{ inputs.base }}..HEAD --no-indent-heuristic --unified=0 --output=pull_diff.txt --no-color
+          git diff ${{ inputs.base }}..${COMMIT} --no-indent-heuristic --unified=0 --output=pull_diff.txt --no-color
         shell: bash
       - name: Lint
+        id: lint
         run: |
           python ci_tools/check_slots.py $GITHUB_STEP_SUMMARY
           python ci_tools/check_pylint_commands.py . pull_diff.txt $GITHUB_STEP_SUMMARY
           cat $GITHUB_STEP_SUMMARY
         shell: bash
+      - name: "Post completed"
+        if: always()
+        run:
+          python ci_tools/complete_check_run.py ${{ steps.lint.outcome }}
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
new file mode 100644
index 0000000000..9f34d6ef07
--- /dev/null
+++ b/.github/workflows/pylint.yml
@@ -0,0 +1,64 @@
+name: Python Linting
+
+on:
+  workflow_dispatch:
+    inputs:
+      python_version:
+        required: true
+        type: string
+      ref:
+        required: false
+        type: string
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
+
+jobs:
+  Linter:
+    runs-on: ubuntu-latest
+    name: Python best practices
+
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
+      - name: Set up Python ${{ inputs.python_version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ inputs.python_version }}
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
+      - name: Check branch
+        run: |
+          git branch
+          git status
+      - name: Install python dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install pylint
+        shell: bash
+      - name: Pylint
+        id: pylint
+        run: |
+          echo $GITHUB_WORKFLOW_SHA
+          python -m pylint --rcfile=.pylintrc pyccel/parser/semantic.py | tee pylint_output.out || true
+          python ci_tools/parse_pylint_output.py pylint_output.out $GITHUB_STEP_SUMMARY
+        shell: bash
+      - name: "Post completed"
+        if: always()
+        run:
+          python ci_tools/complete_check_run.py ${{ steps.pylint.outcome }}
diff --git a/.github/workflows/review_bot.yml b/.github/workflows/review_bot.yml
index 6e2cfafaa2..8167239368 100644
--- a/.github/workflows/review_bot.yml
+++ b/.github/workflows/review_bot.yml
@@ -6,6 +6,7 @@ on:
 
 jobs:
   Bot:
+    if: (! github.event.pull_request.draft && github.event.review.user.type != 'Bot' )
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v3
diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml
index c67a48fb75..3533644fb0 100644
--- a/.github/workflows/spelling.yml
+++ b/.github/workflows/spelling.yml
@@ -1,7 +1,7 @@
 name: Spellcheck Action
 
 on:
-  workflow_call:
+  workflow_dispatch:
     inputs:
       python_version:
         required: true
@@ -9,6 +9,19 @@ on:
       ref:
         required: false
         type: string
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
 
 jobs:
   Spelling:
@@ -18,17 +31,17 @@ jobs:
     steps:
       - uses: actions/checkout@v3
         with:
-          ref: ${{ inputs.ref }}
-      - name: Set up Python 3.8
-        uses: actions/setup-python@v4
-        with:
-          python-version: 3.8
-        if: inputs.python_version == ''
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
       - name: Set up Python ${{ inputs.python_version }}
         uses: actions/setup-python@v4
         with:
           python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip setuptools
@@ -39,8 +52,17 @@ jobs:
         run: |
           sudo apt-get install aspell aspell-en
       - name: Spell check
+        id: spelling
         run: |
            python -m pyspelling > misspellings.txt || true
            cat misspellings.txt
            python ci_tools/summarise_pyspelling.py misspellings.txt $GITHUB_STEP_SUMMARY
-
+      - name: Common spelling problems
+        id: manual_spelling
+        run: |
+          FILES=$(echo -e "import yaml\nfor f in yaml.safe_load(open('.pyspelling.yml'))['matrix'][0]['sources']:\n    print(f)" | python3)
+          python ci_tools/check_python_capitalisation.py $GITHUB_STEP_SUMMARY $FILES
+      - name: "Post completed"
+        if: always()
+        run:
+          python ci_tools/complete_check_run.py ${{ steps.spelling.outcome }} ${{ steps.manual_spelling.outcome }}
diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml
new file mode 100644
index 0000000000..69aac1a6b8
--- /dev/null
+++ b/.github/workflows/welcome.yml
@@ -0,0 +1,26 @@
+name: Bot
+
+on:
+  pull_request_target:
+    types: [ opened, reopened ]
+
+jobs:
+  Bot:
+    name: Thanks for the Pull Request!
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v4
+        with:
+          python-version: 3.9
+      - name: Install python dependencies
+        run: |
+          python -m pip install requests jwt
+      - id: run_bot
+        run: |
+          python ci_tools/bot_hello.py
+        env:
+          GH_TOKEN: ${{ github.token }}
+          PEM: "${{ secrets.BOT_PEM }}"
+
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
index 90f8150c87..f5b47155f1 100644
--- a/.github/workflows/windows.yml
+++ b/.github/workflows/windows.yml
@@ -1,7 +1,7 @@
 name: Windows unit tests
 
 on:
-  workflow_call:
+  workflow_dispatch:
     inputs:
       python_version:
         required: true
@@ -9,6 +9,19 @@ on:
       ref:
         required: false
         type: string
+      check_run_id:
+        required: false
+        type: string
+      pr_repo:
+        required: false
+        type: string
+
+env:
+  COMMIT: ${{ inputs.ref || github.event.ref }}
+  PEM: ${{ secrets.BOT_PEM }}
+  GITHUB_RUN_ID: ${{ github.run_id }}
+  GITHUB_CHECK_RUN_ID: ${{ inputs.check_run_id }}
+  PR_REPO: ${{ inputs.pr_repo || github.repository }}
 
 jobs:
   Windows:
@@ -19,22 +32,17 @@ jobs:
     steps:
       - uses: actions/checkout@v3
         with:
-          ref: ${{ inputs.ref }}
-      - name: Set up Python 3.7
-        uses: actions/setup-python@v4
-        with:
-            # The second most recent version is used as
-            # setup-python installs the most recent patch
-            # which leads to linking problems as there are
-            # 2 versions of python3X.a and the wrong one
-            # is chosen
-          python-version: 3.7
-        if: inputs.python_version == ''
+          ref: ${{ env.COMMIT }}
+          repository: ${{ env.PR_REPO }}
       - name: Set up Python ${{ inputs.python_version }}
         uses: actions/setup-python@v4
         with:
           python-version: ${{ inputs.python_version }}
-        if: inputs.python_version != ''
+      - name: "Setup"
+        id: token
+        run: |
+          pip install jwt requests
+          python ci_tools/setup_check_run.py
       # Uncomment to examine DLL requirements with 'objdump -x FILE'
       #- name: Install mingw tools
       #  uses: msys2/setup-msys2@v2
@@ -46,8 +54,18 @@ jobs:
             python -m pip install .[test]
         shell: bash
       - name: Fortran/C tests with pytest
+        id: f_c_pytest
+        timeout-minutes: 60
         uses: ./.github/actions/pytest_run
       - name: Python tests with pytest
+        id: python_pytest
+        timeout-minutes: 20
         uses: ./.github/actions/pytest_run_python
       - name: Parallel tests with pytest
+        id: parallel
+        timeout-minutes: 20
         uses: ./.github/actions/pytest_parallel
+      - name: "Post completed"
+        if: always()
+        run:
+          python ci_tools/complete_check_run.py ${{ steps.f_c_pytest.outcome }} ${{ steps.python_pytest.outcome }} ${{ steps.parallel.outcome }}
diff --git a/.pyspelling.yml b/.pyspelling.yml
index e6038c2c72..58a2c4ee25 100644
--- a/.pyspelling.yml
+++ b/.pyspelling.yml
@@ -2,7 +2,7 @@ matrix:
 - name: markdown
   sources:
   - README.md
-  - tutorial/*.md
+  - docs/*.md
   - developer_docs/*.md
   - CHANGELOG.md
   aspell:
diff --git a/README.md b/README.md
index 214b17cbc6..9adb4cac87 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Pyccel comes with a selection of **extensions** allowing you to convert calls to
 -   `scipy`
 
 Pyccel's acceleration capabilities lead to much faster code. Comparisons of Python vs Pyccel or other tools can be found in the [benchmarks](https://github.com/pyccel/pyccel-benchmarks) repository.
-The results for the `devel` branch currently show the following performance on python 3.10:
+The results for the `devel` branch currently show the following performance on Python 3.10:
 ![Pyccel execution times for devel branch](https://raw.githubusercontent.com/pyccel/pyccel-benchmarks/main/version_specific_results/devel_performance_310_execution.svg)
 
 If you are eager to try Pyccel out, we recommend reading our [quick-start guide](https://github.com/pyccel/pyccel/blob/devel/docs/quickstart.md).
diff --git a/ci_tools/__init__.py b/ci_tools/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/ci_tools/annotation_helpers.py b/ci_tools/annotation_helpers.py
new file mode 100644
index 0000000000..cfbada25fb
--- /dev/null
+++ b/ci_tools/annotation_helpers.py
@@ -0,0 +1,186 @@
+""" File containing functionalities which help output the CI annotations easily
+"""
+import importlib
+import inspect
+import os
+
+def print_to_string(*args, text):
+    """
+    Print to a string and to terminal.
+
+    Wrapper around the printing function to print the same text to both
+    the terminal and an output string.
+
+    Parameters
+    ----------
+    *args : tuple
+        Positional arguments to print function.
+
+    text : list
+        A list of strings where the output should also be saved.
+    """
+    print(*args)
+    text.append(' '.join(args)+'\n')
+
+def get_code_file_and_lines(obj, pyccel_folder, mod_name = None):
+    """
+    Get the file and the relevant lines for the object.
+
+    From a string (or two strings) describing an object and its module,
+    get the file and the relvant lines where annotations should be
+    printed.
+
+    Parameters
+    ----------
+    obj : str
+        The name of the object being examined. If mod_name is provided
+        then this is the name of the object inside the module, otherwise
+        it may contain the module path.
+
+    pyccel_folder : str
+        The folder containing the pyccel repo.
+
+    mod_name : str, optional
+        The python name of the module (relative to the base folder).
+
+    Returns
+    -------
+    str
+        The name of the file where the object is defined relative to the
+        base folder.
+    int
+        The first line of relevant code.
+    int
+        The last line of relevant code.
+    """
+    if not pyccel_folder:
+        pyccel_folder = os.getcwd()
+
+    obj_parts = obj.split('.')
+    if mod_name is None:
+        idx = len(obj_parts)
+        print(pyccel_folder, obj)
+        filename = os.path.join(pyccel_folder, '/'.join(obj_parts[:idx])+'.py')
+        while idx > 0 and not os.path.isfile(filename):
+            idx -= 1
+            filename = os.path.join(pyccel_folder, '/'.join(obj_parts[:idx])+'.py')
+        assert idx != 0
+        mod_name = '.'.join(obj_parts[:idx])
+        obj_parts = obj_parts[idx:]
+
+    mod = importlib.import_module(mod_name)
+    filename = mod.__file__.split('/')
+    file = os.path.relpath(mod.__file__, pyccel_folder)
+
+    if obj_parts:
+        # Get the object
+        obj = mod
+        for o in obj_parts:
+            obj = getattr(obj, o)
+
+        # If the object is a class property, get the underlying function
+        obj = getattr(obj, 'fget', obj)
+
+        source, start_line = inspect.getsourcelines(obj)
+        length = len(source)
+        return file, start_line, start_line+length-1
+    else:
+        # Module
+        return file, 1, 1
+
+def locate_code_blocks(lines):
+    """
+    Find all code blocks in a markdown file.
+
+    From a list of strings describing the lines in a markdown
+    file, find a list of the start and end line numbers for
+    each of the code blocks.
+
+    Parameters
+    ----------
+    lines : list of str
+        A list of the lines in the file.
+
+    Returns
+    -------
+    list of 2-tuple of ints
+        A list of the start and end line numbers for each of the
+        code blocks.
+    """
+    stripped_lines = [l.strip() for l in lines]
+    code_block_indexes = [i for i, l in enumerate(stripped_lines,1) if l.startswith('```')]
+    nblock_indexes = len(code_block_indexes)
+    assert nblock_indexes % 2 == 0
+    return [b for b in zip(code_block_indexes[::2], code_block_indexes[1::2])]
+
+def is_text(line, start, end, line_number, code_blocks):
+    """
+    Determine if a word in a string is text which should be checked.
+
+    When checking spelling it is important to filter out code snippets
+    and urls. This function takes a line, information about the position
+    of code blocks in the file and the indices of the word being
+    investigated and determines whether the word is relevant or not.
+
+    Parameters
+    ----------
+    line : str
+        The line of interest.
+
+    start : int
+        The index of the start of the word being investigated.
+
+    end : int
+        The index of the end of the word being investigated.
+
+    line_number : int
+        The line number.
+
+    code_blocks : list of 2-tuple of ints
+        The result of a call to locate_code_blocks.
+
+    Returns
+    -------
+    bool
+        True if the word is in text, False if it is in a url or a code snippet.
+    """
+    if any(c[0] <= line_number <= c[1] for c in code_blocks):
+        return False
+    else:
+        n = len(line)
+        idx = -1
+        last_block_was_text = False
+        in_link = False
+        in_url = False
+        while idx < start:
+            if in_link:
+                link_idx = line[idx+1:].find(')')
+                assert link_idx != -1
+                code_idx = n
+                url_idx = n
+            elif in_url:
+                url_idx = line[idx+1:].find('>')
+                assert url_idx != -1
+                code_idx = n
+                link_idx = n
+            else:
+                code_idx = line[idx+1:].find('`')
+                link_idx = line[idx+1:].find('](')
+                url_idx = line[idx+1:].find('<')
+                if code_idx == -1:
+                    code_idx = n
+                if link_idx == -1:
+                    link_idx = n
+                if url_idx == -1:
+                    url_idx = n
+
+            nearest_match = min(code_idx, link_idx, url_idx)
+
+            if nearest_match == url_idx:
+                in_url = not in_url
+            elif nearest_match == link_idx:
+                in_link = not in_link
+            idx += nearest_match+1
+            last_block_was_text = not last_block_was_text
+
+        return last_block_was_text
diff --git a/ci_tools/basic_json_check_output.py b/ci_tools/basic_json_check_output.py
new file mode 100644
index 0000000000..8c192cb7c6
--- /dev/null
+++ b/ci_tools/basic_json_check_output.py
@@ -0,0 +1,30 @@
+""" Summarise the outcome of multiple tests.
+"""
+import argparse
+import json
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--statuses', nargs='+', help="The outcome of each of the tests [success, failure, cancelled, or skipped]", required=True)
+    parser.add_argument('--reasons', nargs='+', help="The reasons to print in case of failure", required=True)
+    p_args = parser.parse_args()
+
+    assert len(p_args.statuses) == len(p_args.reasons)
+
+    summary = "Congratulations! Tests are passing."
+    for status, reason in zip(p_args.statuses, p_args.reasons):
+        if status == "failure":
+            summary = reason
+            break
+        elif status != "success":
+            summary = "Tests cancelled"
+            break
+
+    output_file = 'test_json_result.json'
+
+    json_ouput = {
+        "summary":summary
+    }
+
+    with open(output_file, 'w', encoding='utf-8') as f:
+        json.dump(json_ouput,f)
diff --git a/ci_tools/bot_clean_up.py b/ci_tools/bot_clean_up.py
new file mode 100644
index 0000000000..02bb9db0fa
--- /dev/null
+++ b/ci_tools/bot_clean_up.py
@@ -0,0 +1,103 @@
+""" Script run after a check run to trigger coverage tests if necessary, and change the draft status if necessary.
+"""
+import json
+import os
+from bot_tools.bot_funcs import Bot, test_dependencies
+
+pr_test_keys = ['linux', 'windows', 'macosx', 'coverage', 'docs', 'pylint',
+                'pyccel_lint', 'spelling', 'Codacy']
+
+if __name__ == '__main__':
+    # Parse event payload from $GITHUB_EVENT_PATH variable
+    # (documented here : https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables)
+    # The contents of this json file depend on the triggering event and are
+    # described here :  https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads
+    with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as event_file:
+        event = json.load(event_file)
+
+    name = event['check_run']['name']
+
+    print(event)
+
+    print(event['check_run']['pull_requests'])
+    try:
+        pr_id = next(p['number'] for p in event['check_run']['pull_requests'])
+    except StopIteration:
+        pr_id = 0
+    bot = Bot(pr_id = pr_id, commit = event['check_run']['head_sha'])
+    pr_id = bot.get_pr_id()
+
+    name_key = bot.get_name_key(name)
+
+    runs = bot.get_check_runs()
+
+    print("Runs: ", runs)
+
+    all_run_names = [bot.get_name_key(r['name']) for r in runs]
+    successful_runs = [n for n,r in zip(all_run_names, runs) if r['conclusion'] == "success"]
+    completed_runs = [n for n,r in zip(all_run_names, runs) if r['status'] == "completed"]
+    queued_runs = [r for r in runs if r['status'] == "queued"]
+
+    print("Successful:", successful_runs)
+    print("Completed:", completed_runs)
+
+    for q in queued_runs:
+        q_name = q['name']
+        deps = test_dependencies.get(bot.get_name_key(q_name), ())
+        if name_key in deps:
+            if all(d in successful_runs for d in deps):
+                q_key = q_name.split('(')[1].split(')')[0].strip()
+                q_name, python_version = q_key.split(',')
+                workflow_ids = None
+                if q_name == 'coverage':
+                    workflow_ids = [int(r['details_url'].split('/')[-1]) for r in runs if r['conclusion'] == "success" and '(' in r['name']]
+                bot.run_test(q_name, python_version, q["id"], workflow_ids)
+            elif all(d in completed_runs for d in deps):
+                bot.GAI.update_run(q["id"], {'conclusion':'cancelled', 'status':"completed"})
+
+    draft = bot.is_pr_draft()
+
+    if not draft:
+
+        events = bot.GAI.get_events(pr_id)
+
+        shas = [e.get('sha', None) for e in events]
+        print(shas)
+        print([e.get('event', None) for e in events])
+        page = 1
+        start_idx = -1
+        while start_idx == -1:
+            try:
+                start_idx = next(i for i,s in enumerate(shas) if s == event['check_run']['head_sha'])
+            except StopIteration:
+                start_idx = -1
+                page += 1
+                new_events = bot.GAI.get_events(pr_id, page)
+                events.extend(new_events)
+                shas.extend([e.get('sha', None) for e in new_events])
+                print(shas)
+        try:
+            end_idx = next(i for i,s in enumerate(shas[start_idx+1:], start_idx+1) if s is not None)
+        except StopIteration:
+            end_idx = len(shas)
+
+        relevant_events = events[:end_idx]
+
+        event_types = [e['event'] for e in events]
+
+        relevant_ready_events = [e for e in event_types[:end_idx] if e in ('ready_for_review', 'convert_to_draft')]
+        later_ready_events = [e for e in event_types[end_idx:] if e in ('ready_for_review', 'convert_to_draft')]
+
+        was_examined = relevant_ready_events and relevant_ready_events[-1] == 'ready_for_review'
+        result_ignored = bool(later_ready_events)
+
+        print(was_examined, result_ignored)
+
+        if was_examined and not result_ignored:
+            print(all(k in completed_runs for k in pr_test_keys),
+                 all(k in successful_runs for k in pr_test_keys))
+            if event['check_run']['conclusion'] not in ('success', 'skipped'):
+                bot.draft_due_to_failure()
+            elif all(k in completed_runs for k in pr_test_keys) and \
+                 all(k in successful_runs for k in pr_test_keys):
+                bot.mark_as_ready(following_review = False)
diff --git a/ci_tools/bot_comment_react.py b/ci_tools/bot_comment_react.py
new file mode 100644
index 0000000000..d630f7a3b6
--- /dev/null
+++ b/ci_tools/bot_comment_react.py
@@ -0,0 +1,81 @@
+""" Script run as a reaction to a comment left on a pull request.
+"""
+import json
+import os
+from bot_tools.bot_funcs import Bot, pr_test_keys
+
+def get_unique_test_list(keys):
+    """
+    Get a list of unique tests which should be run.
+
+    Get a list of all the tests which should be run. The treatments to
+    obtain this list remove duplicate tests. It also expands the `pr_tests`
+    key to all tests necessary for a pull request. Finally if coverage is
+    requested, it additionally requests linux.
+
+    Parameters
+    ----------
+    keys : list of str
+        The list of tests requested by the user.
+
+    Returns
+    -------
+    set
+        A set of tests to run.
+    """
+    tests = set(keys)
+    if 'pr_tests' in tests:
+        tests.update(pr_test_keys)
+    tests.discard('pr_tests')
+    if 'coverage' in tests:
+        tests.add('linux')
+    return tests
+
+if __name__ == '__main__':
+    # Parse event payload from $GITHUB_EVENT_PATH variable
+    # (documented here : https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables)
+    # The contents of this json file depend on the triggering event and are
+    # described here :  https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads
+    with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as event_file:
+        event = json.load(event_file)
+
+    # If bot called explicitly (comment event)
+
+    # Collect id from an issue_comment event with a created action
+    pr_id = event['issue']['number']
+
+    comment = event['comment']['body']
+    command = comment.split('/bot')[1].strip()
+    command_words = command.split()
+
+    bot = Bot(pr_id = pr_id)
+
+    if command_words[:2] == ['show', 'tests']:
+        bot.show_tests()
+
+    elif command_words[0] == 'run':
+        if bot.is_user_trusted(event['comment']['user']['login']):
+            bot.run_tests(get_unique_test_list(command_words[1:]))
+        else:
+            bot.warn_untrusted()
+
+    elif command_words[0] == 'try':
+        if bot.is_user_trusted(event['comment']['user']['login']):
+            print(command_words, get_unique_test_list(command_words[2:]), command_words[1])
+            bot.run_tests(get_unique_test_list(command_words[2:]), command_words[1])
+        else:
+            bot.warn_untrusted()
+
+    elif command_words[:3] == ['mark', 'as', 'ready']:
+        if bot.is_user_trusted(event['comment']['user']['login']):
+            if bot.request_mark_as_ready():
+                bot.run_tests(pr_test_keys)
+        else:
+            bot.warn_untrusted()
+
+    elif command_words[:2] == ['trust', 'user'] and len(command_words)==3 and event['comment']['user']['login'] in Bot.trust_givers:
+
+        bot.indicate_trust(command_words[2])
+
+    else:
+        bot.show_commands()
diff --git a/ci_tools/bot_hello.py b/ci_tools/bot_hello.py
new file mode 100644
index 0000000000..a958d6afc9
--- /dev/null
+++ b/ci_tools/bot_hello.py
@@ -0,0 +1,71 @@
+""" Script run following the creation of a new pull request.
+"""
+import json
+import os
+from bot_tools.bot_funcs import Bot, message_from_file
+
+senior_reviewer = ['yguclu', 'EmilyBourne', 'bauom']
+
+def post_first_time_message(event, bot):
+    """
+    Post the welcome message on the pull request.
+
+    Post the welcome message on the pull request indicated in the
+    triggering event. The message is posted using the bot, and
+    differs depending on the trustworthiness of the user (particularly
+    if this is their first pull request or not).
+
+    Parameters
+    ----------
+    event : dict
+        A dictionary describing the triggering event.
+
+    bot : Bot
+        The bot containing the functions for interacting with the
+        repository.
+    """
+    user = event['pull_request']['user']['login']
+    print("Checking trust")
+    # Check whether user is new and/or trusted
+    trusted_user = bot.is_user_trusted(user)
+    print("Current user trust level is : ", event['pull_request']['author_association'])
+    if trusted_user:
+        merged_prs = bot.GAI.get_prs('all')
+        has_merged_pr = any(pr['user']['login'] == user and (pr['merged_at'] is not None) for pr in merged_prs)
+        new_user = not has_merged_pr
+    else:
+        new_user = True
+
+    # Choose appropriate message to welcome author
+    file = 'welcome_newcomer.txt' if new_user else 'welcome_friend.txt'
+
+    bot.leave_comment(message_from_file(file) + message_from_file('checklist.txt'))
+
+    # Ensure PR is draft
+    if not event['pull_request']['draft']:
+        bot.mark_as_draft()
+
+    # If unknown user ask for trust approval
+    if not trusted_user:
+        bot.leave_comment(", ".join(f'@{r}' for r in senior_reviewer)+", please can you check if I can trust this user. If you are happy, let me know with `/bot trust user`")
+
+if __name__ == '__main__':
+    # Parse event payload from $GITHUB_EVENT_PATH variable
+    # (documented here : https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables)
+    # The contents of this json file depend on the triggering event and are
+    # described here :  https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads
+    with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as event_file:
+        event = json.load(event_file)
+
+    # Collect id from a pull request event with an opened action
+    pr_id = event['number']
+
+    bot = Bot(pr_id = pr_id)
+
+    if event['action'] == 'reopened':
+        old_comments = [c for c in bot.GAI.get_comments(pr_id) if c['user']['type'] == 'Bot' and c['body'].startswith('Hello')]
+        print(old_comments)
+        if not old_comments:
+            post_first_time_message(event, bot)
+    else:
+        post_first_time_message(event, bot)
diff --git a/ci_tools/bot_interaction.py b/ci_tools/bot_interaction.py
deleted file mode 100644
index e68848cf7d..0000000000
--- a/ci_tools/bot_interaction.py
+++ /dev/null
@@ -1,608 +0,0 @@
-"""
-This module contains all function and logic responsible for controlling the bot which reacts to PR creation and progress.
-"""
-import argparse
-import json
-import os
-import sys
-from git_evaluation_tools import leave_comment, get_status_json, get_job_information
-from git_evaluation_tools import check_previous_comments, set_ready, set_draft, get_review_status
-from git_evaluation_tools import check_previous_contributions, add_labels, remove_labels, get_labels
-from git_evaluation_tools import get_previous_pr_comments
-
-senior_reviewer = ['yguclu', 'EmilyBourne']
-trusted_reviewers = ['yguclu', 'EmilyBourne', 'ratnania', 'saidctb', 'bauom']
-
-pr_test_keys = ['linux', 'windows', 'macosx', 'coverage', 'docs', 'pylint',
-                'lint', 'spelling']
-
-review_labels = ('needs_initial_review', 'Ready_for_review', 'Ready_to_merge')
-
-comment_folder = os.path.join(os.path.dirname(__file__), 'bot_messages')
-
-icons = {
-        'success' : ':heavy_check_mark:',
-        'failure' : ':x:',
-        'cancelled' : ':no_entry_sign:'
-        }
-
-def get_run_url(event):
-    """
-    Get the URL of the workflow run.
-
-    Use the event information to calculated the URL
-    where the results of the workflow can be viewed.
-
-    Parameters
-    ----------
-    event : dict
-        The event payload of the GitHub workflow.
-
-    Results
-    -------
-    str : The URL.
-    """
-    url = event['repository']['html_url']
-    run_id = event['run_number']
-    return f"{url}/actions/runs/{run_id}"
-
-def run_tests(pr_id, tests, outputs, event):
-    """
-    Run the requested tests and leave a comment on the PR.
-
-    Update the outputs to ensure that the requested tests are run.
-    Leave a comment on the PR using the GitHub CLI to indicate the relevant
-    commit, action and the test status.
-
-    Parameters
-    ----------
-    pr_id : int
-        The number of the PR.
-
-    tests : list of strs
-        The tests requested.
-
-    outputs : dict
-        The dictionary containing the output of the bot.
-
-    event : dict
-        The event payload of the GitHub workflow.
-
-    Returns
-    -------
-    bool
-        Indicates if any tests were triggered.
-    """
-    # Leave a comment to link to the run page
-    ref_sha = get_status_json(pr_id, 'headRefOid')
-    url = get_run_url(event)
-
-    unrecognised = []
-    running = set()
-
-    # Modify the flags to trigger the tests
-    if tests == ['pr_tests']:
-        tests = pr_test_keys
-        running = set(pr_test_keys)
-    for t in tests:
-        key = f'run_{t}'
-        if key in outputs:
-            outputs[key] = True
-            running.add(t)
-        else:
-            unrecognised.append(t)
-
-    if outputs['run_coverage']:
-        outputs['run_linux'] = True
-        running.add('linux')
-
-    running_tests = bool(running)
-
-    if not running_tests:
-        comment = "The requested tests were not recognised. I detected:\n"
-        comment += '\n'.join(f'- "{u}"' for u in unrecognised)
-        comment += "\n\n"
-        comment += message_from_file('show_tests.txt')
-    else:
-        comment = f"Running tests on commit {ref_sha}, for more details see [here]({url})\n"
-        comment += '\n'.join(f'- :hourglass_flowing_sand: {r}' for r in running)
-        if unrecognised:
-            comment += "\n\nThe following additional tests were not recognised:\n"
-            comment += '\n'.join(f'- "{u}"' for u in unrecognised)
-            comment += "\n\n"
-            comment += "To see a list of all test names, please use `/bot show tests`"
-
-    leave_comment(pr_id, comment)
-
-    outputs['status_url'] = event['repository']['statuses_url'].format(sha=ref_sha)
-
-    return running_tests
-
-def check_review_stage(pr_id):
-    """
-    Find the review stage.
-
-    Use the GitHub CLI to examine the reviews left on the pull request
-    and determine the current stage of the review process.
-
-    Parameters
-    ----------
-    pr_id : int
-        The number of the PR.
-
-    Results
-    -------
-    bool : Indicates if the PR is ready to merge.
-
-    bool : Assuming the PR is not ready to merge, indicates if the PR is
-            ready for a review from a senior reviewer.
-
-    requested_changes : List of authors who requested changes.
-
-    reviews : Summary of all reviews left on the PR.
-    """
-    reviews, _ = get_review_status(pr_id)
-    senior_review = [r for a,r in reviews.items() if a in senior_reviewer]
-
-    other_review = [r for a,r in reviews.items() if a not in senior_reviewer]
-
-    ready_to_merge = any(r.state == 'APPROVED' for r in senior_review) and not any(r.state == 'CHANGES_REQUESTED' for r in senior_review)
-
-    ready_for_senior_review = any(r.state == 'APPROVED' for r in other_review) and not any(r.state == 'CHANGES_REQUESTED' for r in other_review)
-
-    requested_changes = [a for a,r in reviews.items() if r.state == 'CHANGES_REQUESTED']
-
-    return ready_to_merge, ready_for_senior_review, requested_changes, reviews
-
-def set_review_stage(pr_id):
-    """
-    Set the flags for the review stage.
-
-    Determine the current stage of the review process from the state
-    of the reviews left on the pull request. Leave the flag indicating
-    that stage and a message encouraging reviews.
-
-    Parameters
-    ----------
-    pr_id : int
-        The number of the PR.
-    """
-    ready_to_merge, ready_for_senior_review, requested_changes, reviews = check_review_stage(pr_id)
-    author = get_status_json(pr_id, 'author')['login']
-    labels = get_labels(pr_id)
-    if ready_to_merge:
-        add_labels(pr_id, ['Ready_to_merge'])
-    elif ready_for_senior_review:
-        if 'Ready_for_review' not in labels:
-            add_labels(pr_id, ['Ready_for_review'])
-            if any(r in requested_changes for r in senior_reviewer):
-                if all(l not in review_labels for l in labels):
-                    requested = ', '.join(f'@{r}' for r in requested_changes)
-                    message = message_from_file('rerequest_review.txt').format(
-                                                    reviewers=requested, author=author)
-                    leave_comment(pr_id, message)
-            else:
-                names = ', '.join(f'@{r}' for r in senior_reviewer)
-                approved = ', '.join(f'@{r.author}' for r in reviews.values() if r.state == 'APPROVED')
-                message = message_from_file('senior_review.txt').format(
-                                reviewers=names, author=author, approved=approved)
-                leave_comment(pr_id, message)
-    else:
-        if all(l not in review_labels for l in labels):
-            add_labels(pr_id, ['needs_initial_review'])
-            if requested_changes:
-                if all(l not in review_labels for l in labels):
-                    requested = ', '.join(f'@{r}' for r in requested_changes)
-                    message = message_from_file('rerequest_review.txt').format(
-                                                    reviewers=requested, author=author)
-                    leave_comment(pr_id, message)
-            else:
-                message = message_from_file('new_pr.txt').format(author=author)
-                leave_comment(pr_id, message)
-
-def mark_as_ready(pr_id):
-    """
-    Mark the pull request as ready for review.
-
-    Use the GitHub CLI to check if the PR is really ready for review.
-    If this is the case then the correct flag is added and the draft
-    status is removed.
-
-    In order to be considered ready for review the PR must:
-    - Have all tests passing
-    - Have a non-trivial description
-
-    Parameters
-    ----------
-    pr_id : int
-        The number of the PR.
-    """
-    running_job_data = get_status_json(pr_id, 'statusCheckRollup')
-
-    data = get_job_information(event['run_number'])
-
-    job_data = [j for j in data if j.get('name','') not in ('Bot', 'CleanUpBot')]
-
-    failures = [j.get('name','') for j in job_data if j['conclusion'] in ('cancelled', 'failure')]
-
-    if any(j.get('name','') == 'Codacy Static Code Analysis' and j['conclusion'] in ('FAILURE', 'ACTION_REQUIRED') for j in running_job_data):
-        failures.append('Codacy Static Code Analysis')
-
-    ignore_coverage = accept_coverage_failure(pr_id)
-    coverage_failed = any('coverage' in f for f in failures) and not ignore_coverage
-    others_failed = any('coverage' not in f for f in failures)
-
-    if coverage_failed and not others_failed:
-        set_draft(pr_id)
-        remove_labels(pr_id, ['Ready_to_merge', 'Ready_for_review', 'needs_initial_review'])
-        leave_comment(pr_id, message_from_file('warn_coverage.txt'))
-
-    elif others_failed:
-        set_draft(pr_id)
-        remove_labels(pr_id, ['Ready_to_merge', 'Ready_for_review', 'needs_initial_review'])
-        message = message_from_file('set_draft_failing.txt')
-        for f in failures:
-            message += f'- {f}\n'
-        leave_comment(pr_id, message)
-    else:
-        set_ready(pr_id)
-
-        set_review_stage(pr_id)
-
-    return 'failure' if failures else 'success'
-
-def message_from_file(filename):
-    """
-    Get the message saved in the file.
-
-    Reads the contents of the file `filename`, located in the
-    folder ./bot_messages/. The extracted string is returned for
-    use as a comment on a PR.
-
-    Parameters
-    ----------
-    filename : str
-        The name of the file to be read
-
-    Results
-    -------
-    str : The message to be printed.
-    """
-    with open(os.path.join(comment_folder, filename), encoding="utf-8") as msg_file:
-        comment = msg_file.read()
-    return comment
-
-def update_test_information(pr_id, event):
-    """
-    Update the PR with the information about the tests.
-
-    Use the GitHub CLI to check the results of the tests. If the last
-    comment made by the bot was for this set of tests then this comment
-    is updated to include the test results. Otherwise a new comment is
-    added with the results.
-
-    Parameters
-    ----------
-    pr_id : int
-        The number of the PR.
-
-    event : dict
-        The event payload of the GitHub workflow.
-    """
-    messages, last_message, _ = check_previous_comments(pr_id)
-
-    data = get_job_information(event['run_number'])
-
-    url = get_run_url(event)
-    relevant_messages = [m for m in messages if url in m]
-    ref_sha = relevant_messages[0].split()[4]
-    comment = f"Ran tests on commit {ref_sha} for more details see [here]({url})\n"
-    passed = True
-    for job in data:
-        conclusion = job['conclusion']
-        name = job['name']
-        if conclusion == 'skipped' or name in ('Bot', 'CleanUpBot'):
-            continue
-        job_passed = (conclusion == 'success')
-        comment += f"- {icons[conclusion]} {name}\n"
-        passed &= job_passed
-
-    leave_comment(pr_id, comment, url in last_message)
-
-    return 'success' if passed else 'failure'
-
-def start_review_check(pr_id, event, outputs):
-    """
-    Check if the review is as ready as the author thinks.
-
-    Use the GitHub CLI to check if the PR has a meaningful
-    description (at least 3 words). If this is the case then the
-    tests are triggered to determine the final draft status.
-
-    Parameters
-    ----------
-    pr_id : int
-        The number of the PR.
-
-    event : dict
-        The event payload of the GitHub workflow.
-
-    outputs : dict
-        The dictionary containing the output of the bot.
-    """
-
-    description = get_status_json(pr_id, 'body')
-    words = description.split()
-
-    if len(words) < 3:
-        leave_comment(pr_id, message_from_file('set_draft_no_description.txt'))
-        set_draft(pr_id)
-        return
-
-    comments = get_previous_pr_comments(pr_id)
-    checklist = [c for c in comments if c.author == 'github-actions' and '- [ ]' in c.body]
-    if checklist:
-        leave_comment(pr_id, message_from_file('set_draft_checklist_incomplete.txt'))
-        set_draft(pr_id)
-        return
-
-    outputs['cleanup_trigger'] = 'request_review_status'
-    run_tests(pr_id, ['pr_tests'], outputs, event)
-
-def accept_coverage_failure(pr_id):
-    """
-    Check if the coverage result has been flagged as acceptable.
-
-    Look through the previous comments on the PR to check if the coverage
-    result has been flagged as acceptable via the `/bot accept coverage`
-    command.
-
-    Parameters
-    ----------
-    pr_id : int
-        The number of the PR.
-
-    user : str
-        The username of the user who may be untrustworthy.
-
-    Results
-    -------
-    bool : True if trustworthy, false otherwise.
-    """
-    comments = get_previous_pr_comments(pr_id)
-    for c in comments:
-        words = c.body.strip().split()
-        if words[:3] == ['/bot', 'accept', 'coverage']:
-            return True
-
-    return False
-
-def flagged_as_trusted(pr_id, user):
-    """
-    Check if the user has been flagged as trusted.
-
-    Look through the previous comments on the PR to check if a trusted
-    user has flagged this user as trusted using the `/bot trust user X`
-    command.
-
-    Parameters
-    ----------
-    pr_id : int
-        The number of the PR.
-
-    user : str
-        The username of the user who may be untrustworthy.
-
-    Results
-    -------
-    bool : True if trustworthy, false otherwise.
-    """
-    trusted_comments = [c for c in get_previous_pr_comments(pr_id) if c.author in trusted_reviewers]
-    for c in trusted_comments:
-        words = c.body.strip().split()
-        if words == ['/bot', 'trust', 'user', user]:
-            return True
-
-    return False
-
-if __name__ == '__main__':
-    parser = argparse.ArgumentParser(description='Call the function to activate the bot')
-    parser.add_argument('gitEvent', type=str,
-                        help='File containing the json description of the triggering event')
-    parser.add_argument('output', type=str,
-                        help='File where the variables should be saved')
-    parser.add_argument('run_id', type=int,
-                        help='The id of the runner (used to identify the action page)')
-    parser.add_argument('cleanup_trigger', type=str, nargs='?', default='',
-                        help='A key to be passed when the bot is used to clean up after tests have run [request_review_status,update_test_information]')
-
-    args = parser.parse_args()
-
-    # Parse event payload from $GITHUB_EVENT_PATH variable
-    # (documented here : https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables)
-    # The contents of this json file depend on the triggering event and are
-    # described here :  https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads
-    with open(args.gitEvent, encoding="utf-8") as event_file:
-        event = json.load(event_file)
-
-    # Save run number into dictionary with event information
-    event['run_number'] = args.run_id
-
-    cleanup_trigger = args.cleanup_trigger
-
-    # Initialise outputs
-    outputs = {'run_linux': False,
-               'run_windows': False,
-               'run_macosx': False,
-               'run_coverage': False,
-               'run_docs': False,
-               'run_pylint': False,
-               'run_lint': False,
-               'run_spelling': False,
-               'run_pickle': False,
-               'run_editable_pickle': False,
-               'run_pickle_wheel': False,
-               'run_linux_anaconda': False,
-               'run_windows_anaconda': False,
-               'cleanup_trigger': '',
-               'python_version': '',
-               'BASE': '',
-               'REF': '',
-               'SHA': ''}
-
-    if cleanup_trigger == 'request_review_status':
-        # Collect id from an issue_comment event with a created action
-        # or from a pull_request event with a synchronize action
-        if 'number' in event:
-            pr_id = event['number']
-        else:
-            pr_id = event['issue']['number']
-        update_test_information(pr_id, event)
-        result = mark_as_ready(pr_id)
-        with open(args.output, encoding="utf-8", mode='a') as out_file:
-            print(f"global_state={result}", file=out_file)
-        sys.exit()
-
-    elif cleanup_trigger == 'update_test_information':
-        # If reporting after run
-
-        # Collect id from a comment event
-        pr_id = event['issue']['number']
-
-        result = update_test_information(pr_id, event)
-        with open(args.output, encoding="utf-8", mode='a') as out_file:
-            print(f"global_state={result}", file=out_file)
-        sys.exit()
-
-    elif 'comment' in event and 'pull_request' in event['issue'] and event['comment']['body'].startswith('/bot'):
-        # If bot called explicitly (comment event)
-
-        # Collect id from an issue_comment event with a created action
-        pr_id = event['issue']['number']
-
-        trusted_user = event['comment']['author_association'] in ('COLLABORATOR', 'CONTRIBUTOR', 'MEMBER', 'OWNER')
-        print("Trust level : ", event['comment']['author_association'])
-        if not trusted_user:
-            trusted_user = flagged_as_trusted(pr_id, event['comment']['user']['login'])
-
-        comment = event['comment']['body']
-        command = comment.split('/bot')[1].strip()
-        command_words = command.split()
-
-        if command_words[0] == 'run':
-            if trusted_user:
-                if run_tests(pr_id, command_words[1:], outputs, event):
-                    outputs['cleanup_trigger'] = 'update_test_information'
-            else:
-                leave_comment(pr_id, message_from_file('untrusted_user.txt'))
-
-        elif command_words[0] == 'try':
-            if trusted_user:
-                outputs['python_version'] = command_words[1]
-                if run_tests(pr_id, command_words[2:], outputs, event):
-                    outputs['cleanup_trigger'] = 'update_test_information'
-            else:
-                leave_comment(pr_id, message_from_file('untrusted_user.txt'))
-
-        elif command == 'mark as ready':
-            if trusted_user:
-                start_review_check(pr_id, event, outputs)
-            else:
-                leave_comment(pr_id, message_from_file('untrusted_user.txt'))
-
-        elif command == 'show tests':
-            leave_comment(pr_id, message_from_file('show_tests.txt'))
-
-        elif command_words[:2] == ['trust', 'user'] and len(command_words)==3:
-            user = command_words[2]
-            leave_comment(pr_id, message_from_file('trusting_user.txt').format(user=user))
-            pr_author = get_status_json(pr_id, 'author')
-            draft = get_status_json(pr_id, 'isDraft')
-            if not draft and user == pr_author:
-                outputs['cleanup_trigger'] = 'request_review_status'
-                run_tests(pr_id, ['pr_tests'], outputs, event)
-
-        elif command_words[:2] == ['accept', 'coverage']:
-            leave_comment(pr_id, message_from_file('coverage_ignored.txt'))
-
-        else:
-            leave_comment(pr_id, message_from_file('bot_commands.txt'))
-
-    elif event['action'] in ('opened', 'reopened'):
-        # If new PR (opened trigger)
-
-        # Collect id from a pull request event with an opened action
-        pr_id = event['number']
-
-        # Check whether user is new and/or trusted
-        trusted_user = event['pull_request']['author_association'] in ('COLLABORATOR', 'CONTRIBUTOR', 'MEMBER', 'OWNER')
-        print("Current user trust level is : ", event['pull_request']['author_association'])
-        if trusted_user:
-            prs = check_previous_contributions(event['repository']['full_name'], event['pull_request']['user']['login'])
-            new_user = (len(prs) == 0)
-        else:
-            new_user = True
-
-        # Choose appropriate message to welcome author
-        file = 'welcome_newcomer.txt' if new_user else 'welcome_friend.txt'
-
-        leave_comment(pr_id, message_from_file(file) + message_from_file('checklist.txt'))
-
-        # Ensure PR is draft
-        if not event['pull_request']['draft']:
-            set_draft(pr_id)
-
-        # If unknown user ask for trust approval
-        if not trusted_user:
-            leave_comment(pr_id, ", ".join(f'@{r}' for r in senior_reviewer)+", please can you check if I can trust this user. If you are happy, let me know with `/bot trust user`")
-
-    elif event['action'] == 'converted_to_draft':
-        # Collect id from a pull_request event with a converted_to_draft action
-        pr_id = event['number']
-
-        remove_labels(pr_id, ['Ready_to_merge', 'Ready_for_review', 'needs_initial_review'])
-
-    elif 'review' in event:
-        pr_id = event['pull_request']['number']
-        state = event['review']['state']
-        if state == 'approved':
-            labels = get_status_json(pr_id, 'labels')
-            remove_labels(pr_id, ['Ready_to_merge', 'Ready_for_review', 'needs_initial_review'])
-            set_review_stage(pr_id)
-        elif state == 'changes_requested':
-            labels = get_status_json(pr_id, 'labels')
-            remove_labels(pr_id, ['Ready_to_merge', 'Ready_for_review', 'needs_initial_review'])
-            set_draft(pr_id)
-            author = get_status_json(pr_id, 'author')['login']
-            reviewer = event['review']['user']['login']
-            leave_comment(pr_id, message_from_file('set_draft_changes.txt').format(author=author, reviewer=reviewer))
-
-    elif 'pull_request' in event and not event['pull_request']['draft']:
-        # If PR is ready for review
-
-        # Collect id from a pull_request event
-        pr_id = event['number']
-        trusted_user = event['pull_request']['author_association'] in ('COLLABORATOR', 'CONTRIBUTOR', 'MEMBER', 'OWNER')
-        if not trusted_user:
-            trusted_user = flagged_as_trusted(pr_id, event['pull_request']['user']['login'])
-
-        if trusted_user:
-            start_review_check(pr_id, event, outputs)
-        else:
-            leave_comment(pr_id, message_from_file('untrusted_user.txt'))
-
-    else:
-        pr_id = None
-
-    if pr_id is not None:
-        status = get_status_json(pr_id, 'baseRefName,headRefOid')
-        outputs['BASE'] = status['baseRefName']
-        outputs['REF'] = f'refs/pull/{pr_id}/merge'
-        outputs['SHA'] = status['headRefOid']
-
-    # Print the output to the file described by $GITHUB_OUTPUT to create outputs
-    # as described here : https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter
-    with open(args.output, encoding="utf-8", mode='a') as out_file:
-        for o,v in outputs.items():
-            print(f"{o}={v}", file=out_file)
-            print(f"{o}={v}")
diff --git a/ci_tools/bot_messages/coverage_ignored.txt b/ci_tools/bot_messages/coverage_ignored.txt
deleted file mode 100644
index a365f8a873..0000000000
--- a/ci_tools/bot_messages/coverage_ignored.txt
+++ /dev/null
@@ -1,1 +0,0 @@
-Thank you for investigating the coverage errors. I will now ignore this result when checking if the PR is ready to merge. Please keep an eye on the results if you make further significant changes to your PR.
diff --git a/ci_tools/bot_messages/coverage_ok.txt b/ci_tools/bot_messages/coverage_ok.txt
new file mode 100644
index 0000000000..a999845f3d
--- /dev/null
+++ b/ci_tools/bot_messages/coverage_ok.txt
@@ -0,0 +1,1 @@
+Good job ! Your PR is using all the code it added/changed.
diff --git a/ci_tools/bot_messages/coverage_review_message.txt b/ci_tools/bot_messages/coverage_review_message.txt
new file mode 100644
index 0000000000..7a11c6fca3
--- /dev/null
+++ b/ci_tools/bot_messages/coverage_review_message.txt
@@ -0,0 +1,6 @@
+There seems to be lines in this PR which aren't tested. Please take a look at my comments and add tests which cover the new code.
+
+If this is modified code which cannot be easily tested in this PR please open an issue to request that this code be either removed or tested. Once you have done that please leave a message on the relevant conversation beginning with the line `/bot accept` and referencing the issue.
+
+Similarly if the new code cannot be tested for some reason, please leave a comment beginning with the line `/bot accept` on the relevant conversation explaining why the code can't be tested.
+
diff --git a/ci_tools/bot_messages/set_draft_checklist_incomplete.txt b/ci_tools/bot_messages/set_draft_checklist_incomplete.txt
index e6fb84bcaa..e651c4a96c 100644
--- a/ci_tools/bot_messages/set_draft_checklist_incomplete.txt
+++ b/ci_tools/bot_messages/set_draft_checklist_incomplete.txt
@@ -1,1 +1,1 @@
-It seems like you have forgotten to complete your checklist for this pull request. Please could you fix that and let me know when everything is ticked and fixed with `/bot mark as ready`.
+It seems like you have forgotten to complete your checklist for this pull request. Please check the list [here]({url}), fix the missing items and let me know when everything is ticked and fixed with `/bot mark as ready`.
diff --git a/ci_tools/bot_messages/set_draft_failing.txt b/ci_tools/bot_messages/set_draft_failing.txt
index a42110555b..7102583cd3 100644
--- a/ci_tools/bot_messages/set_draft_failing.txt
+++ b/ci_tools/bot_messages/set_draft_failing.txt
@@ -1,4 +1,1 @@
 Unfortunately your PR is not passing the tests so it is not quite ready for review yet. Let me know when it is fixed with `/bot mark as ready`.
-
-The failing tests are:
-
diff --git a/ci_tools/bot_messages/warn_coverage.txt b/ci_tools/bot_messages/warn_coverage.txt
deleted file mode 100644
index 7cc3b7b8a4..0000000000
--- a/ci_tools/bot_messages/warn_coverage.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-Unfortunately your PR is not passing the tests so it does not quite seem ready for review yet. The only failing test is the coverage. Please take a look at the result and add tests which cover the new code. Occasionally the new code cannot be easily tested in your PR. If that is the case, please take the following steps:
-
-- [ ] Add a comment to the PR explaining why the coverage cannot be fixed
-- [ ] Open an issue, if necessary, to request that the relevant section(s) of code be investigated and either removed or tested
-- [ ] Tell me that I can ignore this result with `/bot accept coverage`
diff --git a/ci_tools/bot_messages/welcome_friend.txt b/ci_tools/bot_messages/welcome_friend.txt
index 1c084cc6f8..178cde3b82 100644
--- a/ci_tools/bot_messages/welcome_friend.txt
+++ b/ci_tools/bot_messages/welcome_friend.txt
@@ -1,5 +1,3 @@
 Hello again! Thank you for this new pull request :star_struck:.
 
-Don't forget to let me know when it is complete with the command `/bot mark as ready`.
-
-Here is your checklist:
+Here is your checklist. Please tick items off when you have completed them or determined that they are not necessary for this pull request:
diff --git a/ci_tools/bot_messages/welcome_newcomer.txt b/ci_tools/bot_messages/welcome_newcomer.txt
index 94faa78f2a..8051ce083b 100644
--- a/ci_tools/bot_messages/welcome_newcomer.txt
+++ b/ci_tools/bot_messages/welcome_newcomer.txt
@@ -2,7 +2,7 @@ Hello! Welcome to Pyccel! Thank you very much for your contribution :heart:.
 
 I am the GitHub bot. I will help guide you through the different stages necessary to validate a review in Pyccel. If you haven't yet seen our developer docs make sure you check them out [here](https://github.com/pyccel/pyccel/tree/master/developer_docs). Amongst other things they describe the [review process](https://github.com/pyccel/pyccel/blob/master/developer_docs/review_process.md) that we have just started. You can also get in touch with our other developers on our [Pyccel Discord Server](https://discord.gg/2Q6hwjfFVb).
 
-To begin with I will give you a short checklist to make sure your pull request is complete. If you want me to run any specific tests to check out corner cases that you can't easily check on your computer, you can request this using the command `/bot run X`. Use the command `/bot show tests` to see a full list of the tests I can run. Once you have finished preparing your pull request and are ready to request reviews just take your PR out of draft, or let me know with the command `/bot mark as ready`. I will then run the full suite of tests to check that everything is as neat as you think before asking other contributors for reviews. You can get a full list of commands that I understand using `/bot commands`.
+To begin with I will give you a short checklist to make sure your pull request is complete. Please tick items off when you have completed them or determined that they are not necessary for this pull request. If you want me to run any specific tests to check out corner cases that you can't easily check on your computer, you can request this using the command `/bot run X`. Use the command `/bot show tests` to see a full list of the tests I can run. Once you have finished preparing your pull request and are ready to request reviews just take your PR out of draft, or let me know with the command `/bot mark as ready`. I will then run the full suite of tests to check that everything is as neat as you think before asking other contributors for reviews. You can get a full list of commands that I understand using `/bot commands`.
 
 Here is your checklist:
 - [ ] Update AUTHORS file
diff --git a/ci_tools/bot_ready_for_review.py b/ci_tools/bot_ready_for_review.py
new file mode 100644
index 0000000000..8f2493b949
--- /dev/null
+++ b/ci_tools/bot_ready_for_review.py
@@ -0,0 +1,26 @@
+""" Script run when a pull request is marked as ready for review.
+"""
+import json
+import os
+from bot_tools.bot_funcs import Bot, pr_test_keys
+
+if __name__ == '__main__':
+    # Parse event payload from $GITHUB_EVENT_PATH variable
+    # (documented here : https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables)
+    # The contents of this json file depend on the triggering event and are
+    # described here :  https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads
+    with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as event_file:
+        event = json.load(event_file)
+
+    # If bot called explicitly (comment event)
+
+    # Collect id from an issue_comment event with a created action
+    pr_id = event['number']
+
+    bot = Bot(pr_id = pr_id)
+
+    if bot.is_user_trusted(event['sender']['login']):
+        if bot.request_mark_as_ready():
+            bot.run_tests(pr_test_keys)
+    else:
+        bot.warn_untrusted()
diff --git a/ci_tools/bot_tools/__init__.py b/ci_tools/bot_tools/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/ci_tools/bot_tools/bot_funcs.py b/ci_tools/bot_tools/bot_funcs.py
new file mode 100644
index 0000000000..a42a3dce65
--- /dev/null
+++ b/ci_tools/bot_tools/bot_funcs.py
@@ -0,0 +1,772 @@
+""" File containing the class Bot and all functions useful for bot reactions.
+"""
+import json
+import os
+import platform
+import shutil
+import subprocess
+from .github_api_interactions import GitHubAPIInteractions
+
+default_python_versions = {
+        'anaconda_linux': '3.10',
+        'anaconda_windows': '3.10',
+        'coverage': '3.7',
+        'docs': '3.8',
+        'linux': '3.7',
+        'macosx': '3.10',
+        'pickle_wheel': '3.7',
+        'pickle': '3.8',
+        'editable_pickle': '3.8',
+        'pyccel_lint': '3.8',
+        'pylint': '3.8',
+        'spelling': '3.8',
+        'windows': '3.8'
+        }
+
+test_names = {
+        'anaconda_linux': "Unit tests on Linux with anaconda",
+        'anaconda_windows': "Unit tests on Windows with anaconda",
+        'coverage': "Coverage verification",
+        'docs': "Check documentation",
+        'linux': "Unit tests on Linux",
+        'macosx': "Unit tests on MacOSX",
+        'pickle_wheel': "Test pickling during wheel installation",
+        'pickle': "Test pickling during source installation",
+        'editable_pickle': "Test pickling during editable source installation",
+        'pyccel_lint': "Pyccel best practices",
+        'pylint': "Python linting",
+        'spelling': "Spelling verification",
+        'windows': "Unit tests on Windows"
+        }
+
+test_dependencies = {'coverage':['linux']}
+
+tests_with_base = ('coverage', 'docs', 'pyccel_lint')
+
+pr_test_keys = ('linux', 'windows', 'macosx', 'coverage', 'docs', 'pylint',
+                'pyccel_lint', 'spelling')
+
+review_stage_labels = ["needs_initial_review", "Ready_for_review", "Ready_to_merge"]
+
+senior_reviewer = ['yguclu', 'EmilyBourne']
+
+trust_givers = ['yguclu', 'EmilyBourne', 'ratnania', 'saidctb', 'bauom']
+
+comment_folder = os.path.join(os.path.dirname(__file__), '..', 'bot_messages')
+
+github_cli = shutil.which('gh')
+git = shutil.which('git')
+
+def message_from_file(filename):
+    """
+    Get the message saved in the file.
+
+    Reads the contents of the file `filename`, located in the
+    folder ./bot_messages/. The extracted string is returned for
+    use as a comment on a PR.
+
+    Parameters
+    ----------
+    filename : str
+        The name of the file to be read.
+
+    Returns
+    -------
+    str
+        The message to be printed.
+    """
+    with open(os.path.join(comment_folder, filename), encoding="utf-8") as msg_file:
+        comment = msg_file.read()
+    return comment
+
+class Bot:
+    """
+    Class containing all standard bot interactions.
+
+    A class which contains different functionalities for interacting with
+    the bot. This class should be used to avoid duplication elsewhere
+    (e.g. between the bot triggered on a comment and triggered by marking
+    as ready).
+
+    Parameters
+    ----------
+    pr_id : int, optional
+        The number of the PR of interest.
+
+    check_run_id : int, optional
+        The id of the current check run (used to update any details).
+
+    commit : str
+        The SHA of the current commit.
+    """
+
+    def __init__(self, pr_id = None, check_run_id = None, commit = None):
+        self._repo = os.environ["GITHUB_REPOSITORY"]
+        self._GAI = GitHubAPIInteractions()
+        if pr_id is None:
+            self._pr_id = os.environ["PR_ID"]
+        else:
+            self._pr_id = pr_id
+        if self._pr_id != 0:
+            self._pr_details = self._GAI.get_pr_details(pr_id)
+            self._base = self._pr_details["base"]["sha"]
+            self._source_repo = self._pr_details["base"]["repo"]["full_name"]
+        if commit:
+            self._ref = commit
+            if '/' in self._ref:
+                _, _, branch = self._ref.split('/')
+                branch_info = self._GAI.get_branch_details(branch)
+                self._ref = branch_info['commit']['sha']
+        else:
+            self._ref = self._pr_details["head"]["sha"]
+
+        if check_run_id:
+            self._check_run_id = check_run_id
+
+    def create_in_progress_check_run(self, test):
+        """
+        Create a check run for key `test` to describe the run in progress.
+
+        Create a new check run which describes the test `test`. Mark the
+        run as in progress. This means that the test is run by this
+        workflow. The python version used in the key is therefore deduced
+        from the current environment.
+
+        Parameters
+        ----------
+        test : str
+            The key for the test. Must be a key in `default_python_versions`
+            and `test_names`. It should also be the name of a yml file in
+            the folder .github/workflows.
+
+        Returns
+        -------
+        dict
+            A dictionary describing all properties of the new check run.
+
+        Raises
+        ------
+        AssertionError
+            An assertion error is raised if the check run was not successfully posted.
+        """
+        pv = '.'.join(platform.python_version_tuple()[:2])
+        key = f"({test}, {pv})"
+        name = f"{test_names[test]} {key}"
+        posted = self._GAI.create_run(self._ref, name)
+        return posted
+
+    def post_in_progress(self, rerequest = False):
+        """
+        Update a check run to indicate that the run is in progress.
+
+        Update an existing check run using the id specified at the construction
+        to indicate that the run is in progress.
+
+        Parameters
+        ----------
+        rerequest : bool
+            True if the post is due to a test being rerun, False otherwise.
+
+        Returns
+        -------
+        dict
+            A dictionary describing all properties of the check run.
+
+        Raises
+        ------
+        AssertionError
+            An assertion error is raised if the check run was not successfully updated.
+        """
+        if rerequest and self._check_run_id:
+            return self._GAI.rerequest_run(self._check_run_id).json()
+        inputs = {
+                "status":"in_progress",
+                "details_url": f"https://github.com/{self._repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}"
+                }
+        return self._GAI.update_run(self._check_run_id, inputs).json()
+
+    def post_completed(self, conclusion):
+        """
+        Update a check run to indicate that the run is completed.
+
+        Update an existing check run using the id specified at the construction
+        to indicate that the run completed with the specified conclusion.
+
+        Parameters
+        ----------
+        conclusion : str
+            The conclusion of the test. Must be one of:
+            [action_required, cancelled, failure, neutral, success, skipped, stale, timed_out].
+
+        Raises
+        ------
+        AssertionError
+            An assertion error is raised if the check run was not successfully updated.
+        """
+        if os.path.exists('test_json_result.json'):
+            with open('test_json_result.json', 'r', encoding='utf-8') as f:
+                result = json.load(f)
+        else:
+            result = {}
+        print(result)
+        params = {
+                "status": "completed",
+                "conclusion": conclusion,
+                }
+        if result:
+            if "annotations" in result:
+                if(len(result["annotations"]) > 50):
+                    result["annotations"] = result["annotations"][0:50:1]
+            result['title'] = os.environ['GITHUB_WORKFLOW']
+            params["output"] = result
+        try:
+            self._GAI.update_run(self._check_run_id, params)
+        except AssertionError as a:
+            params = {
+                    "status": "completed",
+                    "conclusion": "failure",
+                    }
+            self._GAI.update_run(self._check_run_id, params)
+            raise a
+
+    def show_tests(self):
+        """
+        Print a comment describing the available tests.
+
+        Print a comment on the current pull request describing the available tests.
+        """
+        self._GAI.create_comment(self._pr_id, message_from_file('show_tests.txt'))
+
+    def show_commands(self):
+        """
+        Print a comment describing the available commands.
+
+        Print a comment on the current pull request describing the available bot
+        commands.
+        """
+        self._GAI.create_comment(self._pr_id, message_from_file('bot_commands.txt'))
+
+    def run_tests(self, tests, python_version = None):
+        """
+        Run the specified tests on the requested python version.
+
+        Run the specified tests on the requested python version. If no version
+        is explicitly requested then use the default version as defined in the
+        dictionary default_python_versions.
+
+        Parameters
+        ----------
+        tests : list of str
+            A list of keys for the tests. The keys must be in `default_python_versions`
+            and `test_names`. They should also be the names of yml files in
+            the folder .github/workflows.
+
+        python_version : str, optional
+            The requested python version.
+
+        See Also
+        --------
+        Bot.run_test
+            Called by this function. It runs individual tests but requires more information.
+        """
+        if any(t not in default_python_versions for t in tests):
+            self._GAI.create_comment(self._pr_id, "There are unrecognised tests.\n"+message_from_file('show_tests.txt'))
+        else:
+            check_runs = self._GAI.get_check_runs(self._ref)['check_runs']
+            already_triggered = [c["name"] for c in check_runs if c['status'] in ('completed', 'in_progress')]
+            already_triggered_names = [self.get_name_key(t) for t in already_triggered]
+            already_programmed = {c["name"]:c for c in check_runs if c['status'] == 'queued'}
+            print(already_triggered)
+            for t in tests:
+                pv = python_version or default_python_versions[t]
+                key = f"({t}, {pv})"
+                if any(key in a for a in already_triggered):
+                    continue
+                name = f"{test_names[t]} {key}"
+                if key not in already_programmed:
+                    posted = self._GAI.prepare_run(self._ref, name)
+                else:
+                    posted = already_programmed[key]
+
+                deps = test_dependencies.get(t, ())
+                print(already_triggered_names, deps)
+                if all(d in already_triggered_names for d in deps):
+                    workflow_ids = None
+                    if t == 'coverage':
+                        print([r['details_url'] for r in check_runs if r['conclusion'] == "success"])
+                        workflow_ids = [int(r['details_url'].split('/')[-1]) for r in check_runs if r['conclusion'] == "success" and '(' in r['name']]
+                    self.run_test(t, pv, posted["id"], workflow_ids)
+
+    def run_test(self, test, python_version, check_run_id, workflow_ids = None):
+        """
+        Run the specified test on the specified python version.
+
+        Run the specified test on the specified python version by dispatching the necessary
+        workflow. The check run id must also be provided to the job so the status can be
+        correctly updated from queued. If the job requires any artifacts then workflow_ids
+        of the workflows which provided the artifacts must also be provided.
+
+        Parameters
+        ----------
+        test : str
+            The key for the test. Must be a key in `default_python_versions`
+            and `test_names`. It should also be the name of a yml file in
+            the folder .github/workflows.
+
+        python_version : str
+            The requested python version.
+
+        check_run_id : int
+            The id of the queued check run.
+
+        workflow_ids : list of int, optional
+            The ids of any workflows which may provide the necessary artifacts.
+        """
+        inputs = {'python_version' : python_version,
+                  'ref' : self._ref,
+                  'check_run_id' : str(check_run_id),
+                  'pr_repo' : self._source_repo
+                 }
+        if test in tests_with_base:
+            inputs['base'] = self._base
+        if test == 'coverage':
+            assert workflow_ids is not None
+            possible_artifacts = self._GAI.get_artifacts('coverage-artifact')['artifacts']
+            print("possible_artifacts : ", possible_artifacts)
+            acceptable_urls = [a['archive_download_url'] for a in possible_artifacts if a['workflow_run']['id'] in workflow_ids]
+            print("acceptable_urls: ", acceptable_urls)
+            inputs['artifact_urls'] = ' '.join(acceptable_urls)
+            inputs['pr_id'] = str(self._pr_id)
+        elif test == "editable_pickle":
+            test = "pickle"
+            inputs["editable_string"] = "-e"
+        self._GAI.run_workflow(f'{test}.yml', inputs)
+
+    def mark_as_draft(self):
+        """
+        Mark the pull request as a draft.
+
+        Mark the pull request specified in the constructor as a draft.
+        """
+        cmds = [github_cli, 'pr', 'ready', str(self._pr_id), '--undo']
+
+        with subprocess.Popen(cmds) as p:
+            _, err = p.communicate()
+        print(err)
+
+    def draft_due_to_failure(self):
+        """
+        Mark the pull request as a draft following test failures.
+
+        Mark the pull request specified in the constructor as a draft.
+        This function should be called when one of the pull request
+        check runs has failed.
+        """
+        self.mark_as_draft()
+        self._GAI.create_comment(self._pr_id, message_from_file('set_draft_failing.txt'))
+
+    def request_mark_as_ready(self):
+        """
+        Remove the draft status from the pull request.
+
+        Remove the draft status from the pull request specified in the constructor. This
+        action is only carried out if the pull request has a description and all items
+        on the checklist have been ticked off.
+
+        Returns
+        -------
+        bool
+            Indicates if the pull request could be marked as ready.
+        """
+        description = self._pr_details['body']
+        if description:
+            words = description.split()
+        else:
+            words = []
+
+        if len(words) < 3:
+            self._GAI.create_comment(self._pr_id, message_from_file('set_draft_no_description.txt'))
+            self.mark_as_draft()
+            return False
+
+        welcome_comment = next(c for c in self._GAI.get_comments(self._pr_id) if c['user']['type'] == 'Bot' and c['body'].startswith('Hello'))
+        if '- [ ]' in welcome_comment['body']:
+            self._GAI.create_comment(self._pr_id, message_from_file('set_draft_checklist_incomplete.txt').format(url = welcome_comment['html_url']))
+            self.mark_as_draft()
+            return False
+
+        self.run_tests(pr_test_keys)
+
+        cmds = [github_cli, 'pr', 'ready', str(self._pr_id)]
+
+        with subprocess.Popen(cmds) as p:
+            _, err = p.communicate()
+        print(err)
+        return True
+
+    def mark_as_ready(self, following_review):
+        """
+        Mark a pull request as ready for review  by adding the appropriate labels.
+
+        Mark a pull request as ready for review  by adding the appropriate labels.
+        The review stage is determined via the function check_review_stage. If
+        the stage has changed then a comment is left to indicate who should pay
+        attention to the next stage.
+
+        Parameters
+        ----------
+        following_review : bool
+            True if the stage changed following a review, False if it changed
+            due to exiting draft status.
+        """
+        pr_id = self._pr_id
+        current_labels = self._GAI.get_current_labels(pr_id)
+        print(current_labels)
+        stage_labels = [l["name"] for l in current_labels if l["name"] in review_stage_labels]
+        assert len(stage_labels) <= 1
+        if stage_labels:
+            current_stage = stage_labels[0]
+        else:
+            current_stage = None
+
+        self._GAI.clear_labels(pr_id, stage_labels)
+        new_stage, reviews = self.check_review_stage(pr_id)
+        self._GAI.add_labels(pr_id, [new_stage])
+        author = self._pr_details["user"]["login"]
+        approving_reviewers = [r['user']['login'] for r in reviews if r["state"] == 'APPROVED']
+        requested_changes = [r['user']['login'] for r in reviews if r["state"] == 'CHANGES_REQUESTED']
+
+        if following_review:
+            if review_stage_labels.index(current_stage) < review_stage_labels.index(new_stage):
+                if new_stage == "needs_initial_review":
+                    message = message_from_file('new_pr.txt').format(author=author)
+                    self._GAI.create_comment(pr_id, message)
+                elif new_stage == 'Ready_for_review':
+                    names = ', '.join(f'@{r}' for r in senior_reviewer)
+                    approved = ', '.join(f'@{a}' for a in approving_reviewers)
+                    message = message_from_file('senior_review.txt').format(
+                                    reviewers=names, author=author, approved=approved)
+                    self._GAI.create_comment(pr_id, message)
+        elif reviews:
+            requested = ', '.join(f'@{r}' for r in requested_changes)
+            message = message_from_file('rerequest_review.txt').format(
+                                            reviewers=requested, author=author)
+            self._GAI.create_comment(pr_id, message)
+        else:
+            message = message_from_file('new_pr.txt').format(author=author)
+            self._GAI.create_comment(pr_id, message)
+
+    def post_coverage_review(self, comments, approve):
+        """
+        Create a review describing the coverage results.
+
+        Create a review describing the coverage results. Changes are requested if
+        the test failed. The appropriate message is decided here.
+
+        Parameters
+        ----------
+        comments : list of dict
+            A list of dictionaries describing the comments to be left on code snippets.
+
+        approve : bool
+            Indicates if the review should approve the pull request or not.
+        """
+        if approve:
+            message = message_from_file('coverage_ok.txt')
+            status = 'APPROVE'
+        else:
+            message = message_from_file('coverage_review_message.txt')
+            status = 'REQUEST_CHANGES'
+        self._GAI.create_review(self._pr_id, self._ref, message, status, comments)
+
+    def is_user_trusted(self, user):
+        """
+        Indicate if the user is trusted by the App.
+
+        Detect whether the indicated user is trusted by the App.
+        A user is considered trustworthy if they are in the pyccel-dev
+        team, if they have previously created a PR which was merged, or
+        if a senior dev has declared them trustworthy.
+
+        Parameters
+        ----------
+        user : str
+            The id of the user of interest.
+
+        Returns
+        -------
+        bool
+            True if the user is trusted, false otherwise.
+        """
+        print("Trusted?")
+        in_team = self._GAI.check_for_user_in_team(user, 'pyccel-dev')
+        if in_team["message"] != "Not found":
+            print("In team")
+            return True
+        print("User not in team")
+        merged_prs = self._GAI.get_prs('all')
+        print(merged_prs, user)
+        has_merged_pr = any(pr for pr in merged_prs if pr['user']['login'] == user and pr['merged_at'])
+        if has_merged_pr:
+            print("Merged PR")
+            return has_merged_pr
+        print("User has no merged PRs")
+        comments = self._GAI.get_comments(self._pr_id)
+        comments_from_trust_givers = [c['body'].split() for c in comments if c['user']['login'] in trust_givers]
+        expected_trust_command = ['/bot', 'trust', 'user', user]
+        awarded_trust = any(c[:4] == expected_trust_command for c in comments_from_trust_givers)
+        return awarded_trust
+
+    def warn_untrusted(self):
+        """
+        Print a comment indicating that the user is not trusted.
+
+        Print a comment on the current pull request warning the user that they are not
+        yet trusted.
+        """
+        self._GAI.create_comment(self._pr_id, message_from_file('untrusted_user.txt'))
+
+    def indicate_trust(self, user):
+        """
+        Leave a message explaining that a user was recognised as trustworthy.
+
+        Leave a message on the current pull request indicating that a user was recognised
+        as trustworthy and can now run tests.
+
+        Parameters
+        ----------
+        user : str
+            The id of the user of interest.
+        """
+        if user.startswith('@'):
+            user = user[1:]
+        self._GAI.create_comment(self._pr_id, message_from_file('trusting_user.txt').format(user=user))
+
+    def check_review_stage(self, pr_id):
+        """
+        Find the review stage.
+
+        Use the GitHub CLI to examine the reviews left on the pull request
+        and determine the current stage of the review process. If a senior
+        reviewer has approved then the stage is "Ready_to_merge". Otherwise
+        if everyone else who has left a review (excluding a bot) has approved
+        then the stage is "Ready_for_review". If not then the stage is simply
+        "needs_initial_review".
+
+        Parameters
+        ----------
+        pr_id : int
+            The number of the PR.
+
+        Returns
+        -------
+        str
+            The review stage.
+
+        list of dict
+            A list of the dictionaries describing the reviews left by users (non-bots)
+            which either approved or requested changes.
+        """
+        reviews = [r for r in self._GAI.get_reviews(self._pr_id) if r['user']['type'] != 'Bot' and r['state'] in ('APPROVED', 'CHANGES_REQUESTED')]
+        if any(r['user']['login'] in senior_reviewer and r["state"] == 'APPROVED' for r in reviews):
+            return "Ready_to_merge", reviews
+
+        non_senior_reviews = [r for r in reviews if r['user']['login'] not in senior_reviewer]
+
+        if non_senior_reviews and all(r["state"] == 'APPROVED' for r in non_senior_reviews):
+            return "Ready_for_review", reviews
+
+        else:
+            return "needs_initial_review", reviews
+
+    def get_check_runs(self):
+        """
+        Get a list of all check runs which have run on this commit.
+
+        Get a dictionary containing all information about check runs which have run
+        on the commit specified at the constructor.
+
+        Returns
+        -------
+        dict
+            A dictionary describing the check runs.
+        """
+        return self._GAI.get_check_runs(self._ref)['check_runs']
+
+    def get_bot_review_comments(self):
+        """
+        Get all review comments related to a review left by the bot.
+
+        Get all review comment threads on code snippets for reviews left by the bot.
+        Any outdated comments are discarded and the author is congratulated for fixing
+        their coverage error.
+
+        Returns
+        -------
+        list of list of dict
+            A list of comments on a code snippet. Each element of the list is a list of
+            comments left on one particular snippet.
+        """
+        comments = self._GAI.get_review_comments(self._pr_id)
+        grouped_comments = {}
+
+        for c in comments:
+            c_id = c.get('in_reply_to_id', c['id'])
+            grouped_comments.setdefault(c_id, []).append(c)
+
+        bot_grouped_comments =[c for c in grouped_comments.values() if c[0]['user'].get('type', 'user') == 'Bot']
+
+        relevant_comments = [c for c in bot_grouped_comments if c[0]['position'] is not None]
+        discarded_comments = [c for c in bot_grouped_comments if c[0]['position'] is None]
+
+        for comment_thread in discarded_comments:
+            c = comment_thread[0]
+            self.accept_coverage_fix(comment_thread)
+
+        return relevant_comments
+
+    def get_pr_id(self):
+        """
+        Get the id of the current pull request.
+
+        Get the id of the current pull request. Where this was provided
+        in the constructor it is returned, otherwise pull requests are
+        examined until one is found whose head SHA matches the commit
+        examined here.
+
+        Returns
+        -------
+        int
+            The id of the current pull request.
+        """
+        if self._pr_id:
+            return self._pr_id
+        else:
+            possible_prs = self._GAI.get_prs()
+            print(possible_prs)
+            self._pr_id = next(pr['number'] for pr in possible_prs if pr['head']['sha'] == self._ref)
+            self._pr_details = self._GAI.get_pr_details(self._pr_id)
+            self._base = self._pr_details["base"]["sha"]
+            self._source_repo = self._pr_details["base"]["repo"]["full_name"]
+            return self._pr_id
+
+    def get_diff(self):
+        """
+        Get the diff between the base and the current commit.
+
+        Get the git description of the difference between the current
+        commit and the base commit of the pull request. This output
+        shows how github organises the files tab and allows line
+        numbers do be calculated from git blob positions.
+
+        Returns
+        -------
+        dict
+            A dictionary whose keys are files and whose values are lists of
+            lines which appear in the diff including code and blob headers.
+        """
+        cmd = [git, 'diff', f"{self._base}..{self._ref}"]
+        print(cmd)
+        with subprocess.Popen(cmd + ['--name-only'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) as p:
+            out, _ = p.communicate()
+        diff = {f: None for f in out.strip().split('\n')}
+        for f in diff:
+            with subprocess.Popen(cmd + [f], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) as p:
+                out, err = p.communicate()
+            if not err:
+                lines = out.split('\n')
+                n = next(i for i,l in enumerate(lines) if '@@' in l)
+                diff[f] = lines[n:]
+        return {f:l for f,l in diff.items() if l is not None}
+
+    def accept_coverage_fix(self, comment_thread):
+        """
+        Leave a message on the pull request indicating that the coverage was fixed.
+
+        Leave a message on the pull request specified in the constructor, indicating
+        that the coverage problem designated by the comment in the specified thread,
+        was fixed.
+
+        Parameters
+        ----------
+        comment_thread : list of dict
+            A list of dictionaries describing comments on the same review thread.
+        """
+        message = message_from_file('accept_coverage_fix.txt')
+        print(comment_thread)
+        if any(c['body'] == message for c in comment_thread):
+            return
+        target = comment_thread[0]
+        comment_id = target.get('in_reply_to_id', target['id'])
+        print("id: ", comment_id)
+        reply = self._GAI.create_comment(self._pr_id, message,
+                                 reply_to = comment_id)
+        print(reply.text)
+
+    def is_pr_draft(self):
+        """
+        Indicate whether the pull request is a draft.
+
+        Indicate whether the pull request is a draft.
+
+        Returns
+        -------
+        bool
+            True if draft, False otherwise.
+        """
+        return self._pr_details['draft']
+
+    def leave_comment(self, comment):
+        """
+        Leave a comment on the pull request.
+
+        Leave the specified comment on the pull request.
+
+        Parameters
+        ----------
+        comment : str
+            The comment to be left on the pull request.
+        """
+        self._GAI.create_comment(self._pr_id, comment)
+
+    @property
+    def GAI(self):
+        """
+        Get the GitHubAPIInteractions object.
+
+        Get the GitHubAPIInteractions object to allow for direct
+        manipulation of the API.
+        """
+        return self._GAI
+
+    @property
+    def repo(self):
+        """
+        Get the full name of the repository being handled.
+
+        Get the full name of the repository being handled.
+        """
+        return self._repo
+
+    def get_name_key(self, name):
+        """
+        Get the name used as a key from the full run name.
+
+        Get the name used as a key to dictionaries including test_names and
+        default_python_versions from the full name reported in the check
+        run.
+
+        Parameters
+        ----------
+        name : str
+            The name saved in the check run.
+
+        Returns
+        -------
+        str
+            The name which can be used as a key.
+        """
+        if '(' in name:
+            return name.split('(')[1].split(',')[0]
+        else:
+            return name
diff --git a/ci_tools/bot_tools/github_api_interactions.py b/ci_tools/bot_tools/github_api_interactions.py
new file mode 100644
index 0000000000..e7e36ea077
--- /dev/null
+++ b/ci_tools/bot_tools/github_api_interactions.py
@@ -0,0 +1,709 @@
+""" File containing all functions useful for handling interactions with the GitHub API.
+"""
+import os
+import time
+import jwt
+import requests
+
+def get_authorization():
+    """
+    Get the token necessary to authentificate as the bot.
+
+    Use the private token of the bot (saved in an environment
+    secret) to request a JSON Web Token (JWT). Save that JWT
+    and its expiry date to the environment for future actions
+    ($GITHUB_ENV).
+
+    Returns
+    -------
+    str
+        The JWT used for authentificating as the bot.
+    str
+        A string describing the expiration of the JWT.
+    """
+    print(len(os.environ["PEM"]))
+    signing_key = jwt.jwk_from_pem(bytes(os.environ["PEM"], "utf-8"))
+    # Issued at time
+    # JWT expiration time (10 minutes maximum)
+    # GitHub App's identifier
+    payload = {'iat': int(time.time()), 'exp': int(time.time()) + 60, 'iss': 337566}
+
+    jw_token=jwt.JWT().encode(payload, signing_key, alg='RS256')
+
+    headers = {"Accept": "application/vnd.github+json", "Authorization": f"Bearer {jw_token}", "X-GitHub-Api-Version": "2022-11-28"}
+
+    # Create JWT
+    reply = requests.post("https://api.github.com/app/installations/37820767/access_tokens", headers=headers)
+
+    token  = reply.json()["token"]
+    expiry = reply.json()["expires_at"]
+
+    with open(os.environ["GITHUB_ENV"], "r", encoding='utf-8') as f:
+        output = f.read()
+
+    if "installation_token" in output:
+        lines = output.split('\n')
+        print("Parsed : ", lines)
+        output = '\n'.join(l for l in lines if "installation_token" not in l)
+
+    with open(os.environ["GITHUB_ENV"], "w", encoding='utf-8') as f:
+        f.write(output)
+        print(f"installation_token={token}", file=f)
+        print(f"installation_token_exp={expiry}", file=f)
+
+    return token, expiry
+
+class GitHubAPIInteractions:
+    """
+    Class which handles all interactions with the GitHub API.
+
+    A helper class which exposes the GitHub API in a readable
+    manner.
+    """
+    def __init__(self):
+        repo = os.environ["GITHUB_REPOSITORY"]
+        self._org, self._repo = repo.split('/')
+        if "installation_token" in os.environ:
+            self._install_token = os.environ["installation_token"]
+            self._install_token_exp = time.strptime(os.environ["installation_token_exp"], "%Y-%m-%dT%H:%M:%SZ")
+        else:
+            self._install_token, expiry = get_authorization()
+            self._install_token_exp = time.strptime(expiry, "%Y-%m-%dT%H:%M:%SZ")
+
+    def _post_request(self, method, url, json=None, **kwargs):
+        """
+        Post the request to GitHub.
+
+        Use the requests library to sent the request to the API.
+
+        Parameters
+        ----------
+        method : str
+            The type of request, e.g. GET/POST/PATCH.
+
+        url : str
+            The url where the request should be sent.
+
+        json : dictionary, optional
+            Any additional information provided with the request.
+
+        **kwargs : dictionary
+            Any additional arguments for the requests library.
+
+        Returns
+        -------
+        requests.Response
+            The response collected from the request.
+        """
+        reply = requests.request(method, url, json=json, headers=self.get_headers(), **kwargs)
+        return reply
+
+    def get_branch_details(self, branch_name):
+        """
+        Get the details of the specified branch.
+
+        Use the GitHub API to get information about the mentioned branch.
+
+        Parameters
+        ----------
+        branch_name : str
+            The name of the branch.
+
+        Returns
+        -------
+        dict
+            A dictionary containing information about the branch.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/branches/{branch_name}"
+        return self._post_request("GET", url).json()
+
+    def check_runs(self, commit):
+        """
+        Get a list of all check runs which were run on the commit.
+
+        Get a list of all check runs which were run in the repository for
+        the commit passed as an argument.
+
+        Parameters
+        ----------
+        commit : str
+            The SHA of the commit of interest.
+
+        Returns
+        -------
+        dict
+            A dictionary containing information about the check runs.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/commits/{commit}/check-runs"
+        return self._post_request("GET", url).json()
+
+    def create_run(self, commit, name):
+        """
+        Create a new check run.
+
+        Create a new check run with the specified name which tests the mentioned commit.
+        The check run is marked as in progress. The details url is pointed at the
+        run summary page for this run.
+
+        Parameters
+        ----------
+        commit : str
+            The commit to be tested.
+
+        name : str
+            The name of the check run.
+
+        Returns
+        -------
+        dict
+            A dictionary describing all properties of the new check run.
+
+        Raises
+        ------
+        AssertionError
+            An assertion error is raised if the check run was not successfully posted.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/check-runs"
+        workflow_url = f"https://github.com/{self._org}/{self._repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}"
+        print("create_run:", url)
+        json = {"name": name,
+                "head_sha": commit,
+                "status": "in_progress",
+                "details_url": workflow_url}
+        run = self._post_request("POST", url, json)
+        assert run.status_code == 201
+        return run.json()
+
+    def prepare_run(self, commit, name):
+        """
+        Add a new check run to the queue.
+
+        Create a new check run with the specified name which tests the mentioned commit.
+        The check run is marked as queued.
+
+        Parameters
+        ----------
+        commit : str
+            The commit to be tested.
+
+        name : str
+            The name of the check run.
+
+        Returns
+        -------
+        dict
+            A dictionary describing all properties of the new check run.
+
+        Raises
+        ------
+        AssertionError
+            An assertion error is raised if the check run was not successfully posted.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/check-runs"
+        json = {"name": name,
+                "head_sha": commit,
+                "status": "queued"}
+        run = self._post_request("POST", url, json)
+        assert run.status_code == 201
+        return run.json()
+
+    def update_run(self, run_id, json):
+        """
+        Update an existing check run.
+
+        Update information on the check run with id "run_id" using the information
+        in the json dictionary as described here:
+        https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#update-a-check-run
+
+        Parameters
+        ----------
+        run_id : int
+            The id of the check run.
+
+        json : dictionary
+            The information that should be updated in the check run.
+
+        Returns
+        -------
+        requests.Response
+            The response collected from the request.
+
+        Raises
+        ------
+        AssertionError
+            An assertion error is raised if the check run was not successfully updated.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/check-runs/{run_id}"
+        run = self._post_request("PATCH", url, json)
+        print(run.text)
+        assert run.status_code == 200
+        return run
+
+    def rerequest_run(self, run_id):
+        """
+        Rerequest an existing check run.
+
+        Rerequest the check run with id "run_id" as described here:
+        https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#rerequest-a-check-run
+
+        Parameters
+        ----------
+        run_id : int
+            The id of the check run.
+
+        Returns
+        -------
+        requests.Response
+            The response collected from the request.
+
+        Raises
+        ------
+        AssertionError
+            An assertion error is raised if the check run was not successfully rerequested.
+        """
+        print("Rerequesting : ", run_id)
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/check-runs/{run_id}/rerequest"
+        run = self._post_request("POST", url)
+        print(run.text)
+        assert run.status_code == 201
+        run_url = f"https://api.github.com/repos/{self._org}/{self._repo}/check-runs/{run_id}"
+        return self._post_request("GET", run_url)
+
+    def get_pr_details(self, pr_id):
+        """
+        Get the details of a pull request.
+
+        Get all details provided by the API for a given pull request.
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request.
+
+        Returns
+        -------
+        dict
+            A dictionary describing the properties of the pull request.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/pulls/{pr_id}"
+        return self._post_request("GET", url).json()
+
+    def run_workflow(self, filename, inputs):
+        """
+        Create a workflow dispatch event.
+
+        Create a workflow dispatch event as described in the API docs here:
+        https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28
+
+        All workflows are run from the devel branch, they then use the inputs
+        to checkout the relevant code.
+
+        Parameters
+        ----------
+        filename : str
+            The name of the file containing the workflow we wish to run.
+
+        inputs : dict
+            A dictionary of any inputs required for the workflow.
+
+        Raises
+        ------
+        AssertionError
+            An assertion error is raised if the workflow was not successfully started.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/actions/workflows/{filename}/dispatches"
+        json = {"ref": "devel",
+                "inputs": inputs}
+        print(url, json)
+        reply = self._post_request("POST", url, json)
+        print(reply.text)
+        assert reply.status_code == 204
+
+    def get_comments(self, pr_id):
+        """
+        Get all comments left on a given pull request or issue.
+
+        Get a dictionary containing a list of all the comments left
+        on a given pull request or issue. This list is obtained using
+        the API as described here:
+        https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request or comment.
+
+        Returns
+        -------
+        dict
+            A dictionary containing the comments.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/issues/{pr_id}/comments"
+        return self._post_request("GET", url).json()
+
+    def get_review_comments(self, pr_id):
+        """
+        Get all review comments left on a given pull request.
+
+        Get a dictionary containing a list of all the review comments left
+        on a given pull request. This includes comments left as a reply to a
+        review comment on a code snippet. This list is obtained using
+        the API as described here:
+        https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request or comment.
+
+        Returns
+        -------
+        dict
+            A dictionary containing the comments.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/pulls/{pr_id}/comments"
+        return self._post_request("GET", url).json()
+
+    def create_comment(self, pr_id, comment, reply_to = None):
+        """
+        Create a comment on a pull request or issue.
+
+        Create a comment on a pull request or issue using the API as
+        described here:
+        https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28
+
+        Comments can also be left as a reply to a review comment on
+        a code snippet. This requires the id of the original comment
+        to be provided.
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request or comment.
+
+        comment : str
+            The message to be left in the comment.
+
+        reply_to : int, optional
+            The id of the comment being replied to.
+
+        Returns
+        -------
+        requests.Response
+            The response collected from the request.
+        """
+        if reply_to:
+            suffix = f"/{reply_to}/replies"
+            issue_type = 'pulls'
+        else:
+            issue_type = 'issues'
+            suffix = ''
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/{issue_type}/{pr_id}/comments{suffix}"
+        print(url)
+        return self._post_request("POST", url, json={"body":comment})
+
+    def create_review(self, pr_id, commit, comment, status, comments = ()):
+        """
+        Create a review on the specified pull request.
+
+        Create a review on the specified pull request using the API as
+        described here:
+        https://docs.github.com/en/rest/pulls/reviews?apiVersion=2022-11-28#create-a-review-for-a-pull-request
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request or comment.
+
+        commit : str
+            The SHA of the most recent commit at the moment of the review.
+
+        comment : str
+            The message to be left in the review.
+
+        status : str
+            The status of the review, [REQUEST_CHANGES/APPROVE].
+
+        comments : list of dictionaries
+            A list of dictionaries describing the comments to be left on code snippets.
+
+        Returns
+        -------
+        requests.Response
+            The response collected from the request.
+
+        Raises
+        ------
+        AssertionError
+            An assertion error is raised if the review was not successfully posted.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/pulls/{pr_id}/reviews"
+        review = {'commit_id':commit, 'body': comment, 'event': status, 'comments': comments}
+        print(review)
+        reply = self._post_request("POST", url, json=review)
+        print(reply.text)
+        assert reply.status_code == 200
+        return reply
+
+    def check_for_user_in_team(self, user, team):
+        """
+        Check to determine if a user belongs to a given team.
+
+        Use the API to check to determine if a user belongs to a given team
+        as described here:
+        https://docs.github.com/en/rest/teams/members?apiVersion=2022-11-28#get-team-membership-for-a-user
+
+        Parameters
+        ----------
+        user : str
+            The user of interest.
+
+        team : str
+            The team which we are checking.
+
+        Returns
+        -------
+        dict
+            A dictionary describing the result.
+        """
+        url = f'https://api.github.com/orgs/{self._org}/teams/{team}/membersips/{user}'
+        return self._post_request("GET", url).json()
+
+    def get_prs(self, state='open'):
+        """
+        Get a list of all pull requests in the repository.
+
+        Use the API to get a list of all pull requests in the repository which have
+        the specified state as described here:
+        https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests
+
+        Parameters
+        ----------
+        state : str, default='open'
+            The state of the pull requests to report [open/closed/all].
+
+        Returns
+        -------
+        dict
+            A dictionary describing the pull requests.
+        """
+        url = f'https://api.github.com/repos/{self._org}/{self._repo}/pulls'
+        return self._post_request("GET", url, params={'state':state}).json()
+
+    def get_check_runs(self, commit):
+        """
+        Get a list of all check runs which have run on a given commit.
+
+        Use the API to get a list of all check runs which have run on a given
+        commit as described here:
+        https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#list-check-runs-for-a-git-reference
+
+        Parameters
+        ----------
+        commit : str
+            The SHA of the most recent commit at the moment of the review.
+
+        Returns
+        -------
+        dict
+            A dictionary describing the check runs.
+        """
+        url = f'https://api.github.com/repos/{self._org}/{self._repo}/commits/{commit}/check-runs'
+        return self._post_request("GET", url).json()
+
+    def get_pr_events(self, pr_id):
+        """
+        Get a list of all events which occured on this pull request.
+
+        Use the API to get a list of all events which occured on this pull
+        request as described here:
+        https://docs.github.com/en/rest/issues/events?apiVersion=2022-11-28#list-issue-events
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request.
+
+        Returns
+        -------
+        dict
+            A dictionary describing the events.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/issues/{pr_id}/timeline"
+        return self._post_request("GET", url).json()
+
+    def get_artifacts(self, name):
+        """
+        Find all artifacts with the specified name.
+
+        Find all artifacts in the repository with the specified name using
+        the API as described here:
+        https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28#list-artifacts-for-a-repository
+
+        Parameters
+        ----------
+        name : str
+            The name of the artifact.
+
+        Returns
+        -------
+        dict
+            A dictionary describing all artifacts found.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/actions/artifacts"
+        query= {'name': name}
+        return self._post_request("GET", url, query).json()
+
+    def download_artifact(self, name, url):
+        """
+        Download the specified artifact from the url.
+
+        Use the API to download the specified artifact from the url
+        into a file called `name` as described here:
+        https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28#download-an-artifact
+
+        Parameters
+        ----------
+        name : str
+            The name of the file where the result should be saved.
+
+        url : str
+            The url where the file is located.
+        """
+        reply = self._post_request("GET", url, stream=True)
+        with open(name, 'wb') as f:
+            f.write(reply.content)
+
+    def get_reviews(self, pr_id):
+        """
+        Get a list of all reviews which have been left on a given pull request.
+
+        Use the API to get a list of all reviews left on a given pull request
+        as described here:
+        https://docs.github.com/en/rest/pulls/reviews?apiVersion=2022-11-28#list-reviews-for-a-pull-request
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request.
+
+        Returns
+        -------
+        dict
+            A dictionary describing the reviews.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/pulls/{pr_id}/reviews"
+        return self._post_request("GET", url).json()
+
+    def get_events(self, pr_id, page = 1):
+        """
+        Get a timeline of events which occured on a given pull request.
+
+        Use the API to get a list of events on a pull request as described
+        here:
+        https://docs.github.com/en/rest/issues/timeline?apiVersion=2022-11-28
+
+        These events are described here:
+        https://docs.github.com/en/webhooks-and-events/events/issue-event-types
+
+        The events are loaded in pages of 100 events. If the full history is
+        required then this function must be called multiple times increasing
+        the page argument each time.
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request.
+
+        page : int
+            The page to load.
+
+        Returns
+        -------
+        dict
+            A dictionary describing the events.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/issues/{pr_id}/timeline"
+        configs = {'per_page':100, 'page': page}
+        return self._post_request("GET", url, params=configs).json()
+
+    def clear_labels(self, pr_id, labels):
+        """
+        Remove the specified labels from the indicated pull request.
+
+        Use the API to remove the specified labels from the indicated pull
+        request as described here:
+        https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#remove-a-label-from-an-issue
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request.
+
+        labels : list of str
+            A list containing the names of the labels to be removed.
+        """
+        for l in labels:
+            url = f"https://api.github.com/repos/{self._org}/{self._repo}/issues/{pr_id}/labels/{l}"
+            self._post_request("DELETE", url)
+
+    def add_labels(self, pr_id, labels):
+        """
+        Add the specified labels to the indicated pull request.
+
+        Use the API to add the specified labels from the indicated pull
+        request as described here:
+        https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#add-labels-to-an-issue
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request.
+
+        labels : list of str
+            A list containing the names of the labels to be added.
+        """
+        assert labels
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/issues/{pr_id}/labels"
+        self._post_request("POST", url, {"labels":labels})
+
+    def get_current_labels(self, pr_id):
+        """
+        Get a description of all labels currently on the pull request.
+
+        Use the API to get a description of all labels currently used
+        on the specified pull request.
+
+        Parameters
+        ----------
+        pr_id : int
+            The id of the pull request.
+
+        Returns
+        -------
+        list of dict
+            A list of dictionaries describing each of the labels.
+        """
+        url = f"https://api.github.com/repos/{self._org}/{self._repo}/issues/{pr_id}/labels"
+        return self._post_request("GET", url).json()
+
+    def get_headers(self):
+        """
+        Get the header which is always passed to the API.
+
+        Get the header which must always be passed to the API to authentificate.
+        This header specifies the identity of the bot and the reference version
+        of the GitHub API.
+
+        Returns
+        -------
+        dict
+            The header which should be used in requests.
+        """
+        if self._install_token_exp < time.struct_time(time.gmtime()):
+            self._install_token, expiry = get_authorization()
+            self._install_token_exp = time.strptime(expiry, "%Y-%m-%dT%H:%M:%SZ")
+
+        return {"Accept": "application/vnd.github+json",
+                 "Authorization": f"Bearer {self._install_token}",
+                 "X-GitHub-Api-Version": "2022-11-28"}
diff --git a/ci_tools/check_new_coverage.py b/ci_tools/check_new_coverage.py
index 8d557cd5bd..9868b780e8 100644
--- a/ci_tools/check_new_coverage.py
+++ b/ci_tools/check_new_coverage.py
@@ -1,32 +1,50 @@
 """ Script to check that all new lines in the python files in the pyccel/ code folder are used in the tests
 """
-import json
 import argparse
+import os
 from git_evaluation_tools import get_diff_as_json
+from bot_tools.bot_funcs import Bot
 import coverage_analysis_tools as cov
 
-parser = argparse.ArgumentParser(description='Check that all new lines in the python files in the pyccel/ code folder are used in the tests')
-parser.add_argument('diffFile', metavar='diffFile', type=str,
-                        help='File containing the git diff output')
-parser.add_argument('coverageFile', metavar='coverageFile', type=str,
-                        help='File containing the coverage xml output')
-parser.add_argument('commit', metavar='commit', type=str,
-                        help='The commit being analysed')
-parser.add_argument('output', metavar='output', type=str,
-                        help='File where the markdown output will be printed')
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description='Check that all new lines in the python files in the pyccel/ code folder are used in the tests')
+    parser.add_argument('diffFile', metavar='diffFile', type=str,
+                            help='File containing the git diff output')
+    parser.add_argument('coverageFile', metavar='coverageFile', type=str,
+                            help='File containing the coverage xml output')
+    parser.add_argument('output', metavar='output', type=str,
+                            help='File where the markdown output will be printed')
 
-args = parser.parse_args()
+    args = parser.parse_args()
 
-diff = get_diff_as_json(args.diffFile)
-untested, file_contents = cov.get_untested_lines(args.coverageFile)
+    bot = Bot(pr_id = os.environ["PR_ID"], check_run_id = os.environ["CHECK_RUN_ID"], commit = os.environ['HEAD_SHA'])
 
-new_untested = cov.compare_coverage_to_diff(untested, diff)
+    current_diff = bot.get_diff()
 
-new_untested = cov.allow_untested_error_calls(new_untested)
+    revs = bot.get_bot_review_comments()
 
-new_untested = cov.allow_untested_debug_code(new_untested)
+    commented_lines = {(r[0]['path'], r[0]['line']): r for r in revs}
 
-cov.print_markdown_summary(new_untested, file_contents, args.commit, args.output)
+    diff = get_diff_as_json(args.diffFile)
+    untested, file_contents = cov.get_untested_lines(args.coverageFile)
 
-cov.show_results(new_untested)
+    new_untested = cov.compare_coverage_to_diff(untested, diff)
 
+    new_untested = cov.allow_untested_error_calls(new_untested)
+
+    new_untested = cov.allow_untested_debug_code(new_untested)
+
+    old_comments, new_comments, fixed_comments = cov.get_json_summary(new_untested, file_contents, commented_lines)
+
+    success = cov.evaluate_success(old_comments, new_comments, commented_lines)
+
+    cov.print_markdown_summary(old_comments + new_comments, os.environ['COMMIT'], args.output, bot.repo)
+
+    bot.post_coverage_review(new_comments, success)
+
+    for r in fixed_comments.values():
+        bot.accept_coverage_fix(r)
+
+    bot.post_completed('success' if success else 'failure')
+
+    cov.show_results(new_untested)
diff --git a/ci_tools/check_pylint_commands.py b/ci_tools/check_pylint_commands.py
index 063b1dab38..c771f096f4 100644
--- a/ci_tools/check_pylint_commands.py
+++ b/ci_tools/check_pylint_commands.py
@@ -7,6 +7,7 @@
 import shutil
 import subprocess
 import sys
+import json
 from git_evaluation_tools import get_diff_as_json
 
 accepted_pylint_commands = {re.compile('.*/IMPORTING_EXISTING_IDENTIFIED3.py'):['reimported'],
@@ -14,13 +15,13 @@
                             re.compile('.*/UNKNOWN_IMPORT.py'):['unused-import'],
                             re.compile('.*/UNKNOWN_IMPORT2.py'):['unused-import'],
                             re.compile('.*/USELESS_EXPRESSION.py'):['pointless-statement'],
-                            re.compile('./tests/errors/known_bugs/dicts.py'):['pointless-statement'],
+                            re.compile('tests/errors/known_bugs/dicts.py'):['pointless-statement'],
                             re.compile('.*/syntax/.*'):['pointless-statement','undefined-variable'],
-                            re.compile('./tests/codegen/fcode/scripts/precision.py'):['unused-variable'],
-                            re.compile('./tests/semantic/scripts/expressions.py'):['unused-variable'],
-                            re.compile('./tests/semantic/scripts/calls.py'):['unused-variable']}
+                            re.compile('tests/codegen/fcode/scripts/precision.py'):['unused-variable'],
+                            re.compile('tests/semantic/scripts/expressions.py'):['unused-variable'],
+                            re.compile('tests/semantic/scripts/calls.py'):['unused-variable']}
 
-def check_expected_pylint_disable(file, disabled, flag, messages):
+def run_pylint(file, flag, messages):
     """
     Check for an expected pylint disable flag.
 
@@ -29,6 +30,42 @@ def check_expected_pylint_disable(file, disabled, flag, messages):
     file raises the error a message is saved recommending that the
     flag be disabled in the file.
 
+    Parameters
+    ----------
+    file : file object
+        The file being analysed.
+    flag : str
+        The name of the flag being investigated.
+    messages : list
+        The list of messages which should be printed.
+    """
+    with subprocess.Popen([shutil.which('pylint'), file, '--disable=all', f'--enable={flag}']) as r:
+        r.communicate()
+        result = r.returncode
+    if result:
+        output_item = {
+            "annotations": [],
+            "flags":flag,
+            "summary":"-  Feel free to disable",
+        }
+        output_item["annotations"].append({
+            "annotation_level":"warning",
+            "start_line":1,
+            "end_line":1,
+            "path":file,
+            "message":f"Feel free to disable `{flag}`"
+        })
+        messages.append(output_item)
+
+def check_expected_pylint_disable(file, disabled, flag, messages, file_changed):
+    """
+    Check for an expected pylint disable flag.
+
+    Check for an expected pylint disable flag. If the flag is present
+    then it is ignored by removing it from the list. Otherwise, if the
+    file is modified in this pull request, and it raises the error, a
+    message is saved recommending that the flag be disabled in the file.
+
     Parameters
     ----------
     file : file object
@@ -39,15 +76,21 @@ def check_expected_pylint_disable(file, disabled, flag, messages):
         The name of the flag being investigated.
     messages : list
         The list of messages which should be printed.
+    file_changed : bool
+        Indicates whether the file was changed in this diff.
     """
-    if flag in disabled:
-        disabled.remove(flag)
-    else:
-        with subprocess.Popen([shutil.which('pylint'), file, '--disable=all', f'--enable={flag}']) as r:
-            r.communicate()
-            result = r.returncode
-        if result:
-            messages.append(f"Feel free to disable `{flag}` in `{file}`")
+    disabled_copy = disabled.copy()
+    if disabled:
+        for flags, line_number in disabled_copy:
+            if flag in flags:
+                new_flags = tuple(value for value in flags if value != flag)
+                disabled.remove((flags, line_number))
+                if new_flags:
+                    disabled.add((new_flags, line_number))
+            elif file_changed:
+                run_pylint(file, flag, messages)
+    elif file_changed:
+        run_pylint(file, flag, messages)
 
 if __name__ == '__main__':
     parser = argparse.ArgumentParser(description='Check that all new lines in the python files in the pyccel/ code folder are used in the tests')
@@ -64,44 +107,116 @@ def check_expected_pylint_disable(file, disabled, flag, messages):
 
     folder = args.folder
 
-    files = [os.path.join(root,f) for root, dirs, filenames in os.walk(folder) for f in filenames if os.path.splitext(f)[1] == '.py']
+    files = [os.path.relpath(os.path.join(root,f)) for root, dirs, filenames in os.walk(folder) for f in filenames if os.path.splitext(f)[1] == '.py']
 
     success = True
 
-    messages = []
+    messages = {"title":"Pyccel_lint","summary":"## Pylint Interaction:\n\n","annotations":[]}
 
     for f in files:
         with open(f, encoding="utf-8") as myfile:
             lines = [l.replace(' ','') for l in myfile.readlines()]
-        pylint_lines = [l.strip() for l in lines if l.startswith('#pylint:disable=')]
+        pylint_lines_and_numbers = [(l.strip(), i) for i,l in enumerate(lines,1) if l.startswith('#pylint:disable=')]
         disabled = set()
-        for l in pylint_lines:
-            disabled.update(l.split('=')[1].split(','))
+        for value, key in pylint_lines_and_numbers:
+            disabled.update([(tuple(value.split('=')[1].split(',')), key)])
         for r,d in accepted_pylint_commands.items():
             if r.match(f):
                 for di in d:
-                    disabled.discard(di)
+                    updated_disabled = disabled.copy()
+                    for item in updated_disabled:
+                        statements, num = item
+                        strings_list = list(statements)
+                        if di in strings_list:
+                            strings_list.remove(di)
+                            disabled.discard(item)
+                            if strings_list:
+                                disabled.update([(tuple(strings_list), num)])
+        file_changed = f in diff
         p = pathlib.Path(f)
         if p.parts[0] == 'tests':
-            check_expected_pylint_disable(f, disabled, 'missing-function-docstring', messages)
-            check_expected_pylint_disable(f, disabled, 'missing-module-docstring', messages)
-            check_expected_pylint_disable(f, disabled, 'missing-class-docstring', messages)
+            msg = []
+            check_expected_pylint_disable(f, disabled, 'missing-function-docstring', msg, file_changed)
+            check_expected_pylint_disable(f, disabled, 'missing-module-docstring', msg, file_changed)
+            check_expected_pylint_disable(f, disabled, 'missing-class-docstring', msg, file_changed)
+            first_iteration = True
+            for item in msg:
+                if first_iteration:
+                    messages['summary'] += item['summary'] + ' `' + item['flags'] + '`'
+                    for value in item['annotations']:
+                        messages['annotations'].append(value)
+                    first_iteration = False
+                else:
+                    messages['summary'] += ', `' + item['flags'] + '`'
+                    messages['annotations'][-1]['message'] += ', ' + item['flags']
+            if not first_iteration:
+                messages['summary'] += f' in `{f}`\n\n'
+                messages['annotations'][-1]['message'] += f' in {f}'
             if p.parts[1] == 'epyccel':
-                disabled.discard('reimported')
+                updated_disabled = disabled.copy()
+                for item in updated_disabled:
+                    statements, num = item
+                    strings_list = list(statements)
+                    if 'reimported' in strings_list:
+                        strings_list.remove('reimported')
+                        disabled.discard(item)
+                        if strings_list:
+                            disabled.update([(tuple(strings_list), num)])
         if disabled:
-            file_changed = f in diff
-            disabled_str = ", ".join(f"`{d}`" for d in disabled)
+            first_iteration = True
             if file_changed:
-                messages.append(f"[ERROR] New unexpected pylint disables found in `{f}`: {disabled_str}")
+                summary_template = f"-  New unexpected pylint disables found in `{f}`: "
+                annotation_level = "failure"
+                annotation_message = "[ERROR] New unexpected pylint disables: "
             else:
-                messages.append(f"Unexpected pylint disables found in `{f}`: {disabled_str}")
+                summary_template = f"-  Unexpected pylint disables found in `{f}`: "
+                annotation_level = "warning"
+                annotation_message = "Unexpected pylint disables: "
+            first_iteration = True
+            for value, key in disabled:
+                for v in value:
+                    if first_iteration:
+                        messages['summary'] += f"{summary_template}`{v}`"
+                        messages['annotations'].append({
+                            'path':f,
+                            'start_line':key,
+                            'end_line':key,
+                            'annotation_level':annotation_level,
+                            'message':f"{annotation_message}{v}"})
+                        first_iteration = False
+                    else:
+                        messages['summary'] += ', `' + v + '`'
+                        if key == messages['annotations'][-1]['start_line']:
+                            messages['annotations'][-1]['message'] += ', ' + v
+                        else:
+                            messages['annotations'].append({
+                                'path':f,
+                                'start_line':key,
+                                'end_line':key,
+                                'annotation_level':annotation_level,
+                                'message':f"{annotation_message}{v}"})
+            if not first_iteration:
+                messages['summary'] += '\n\n'
             success &= (not file_changed)
 
-    if messages:
-        with open(args.output, mode='a', encoding="utf-8") as outfile:
-            print("## Pylint Interaction", file=outfile)
-            for m in messages:
-                print(m, file=outfile)
+    if not messages['summary'] and success:
+        messages['summary'] = "## Pylint Interaction:\n\n**Success**:The operation was successfully completed. All necessary tasks have been executed without any errors or warnings."
+        messages.pop('annotations')
+    if not success and not messages['summary']:
+        messages['summary'] = "## Pylint Interaction:\n\n**Error**: Something went wrong"
+        messages.pop('annotations')
+
+    with open('test_json_result.json', mode='r', encoding="utf-8") as json_file:
+        slots_data = json.load(json_file)
+        slots_data['summary'] += messages['summary']
+        if "annotations" in slots_data:
+            slots_data['annotations'].extend(messages['annotations'])
+        elif messages['annotations']:
+            slots_data.update({'annotations': messages['annotations']})
+    with open('test_json_result.json', mode='w', encoding="utf-8") as json_file:
+        json.dump(slots_data, json_file)
+    with open(args.output, mode='a', encoding="utf-8") as md_file:
+        md_file.write(messages['summary'])
 
     if not success:
         sys.exit(1)
diff --git a/ci_tools/check_python_capitalisation.py b/ci_tools/check_python_capitalisation.py
new file mode 100644
index 0000000000..25706b6e3f
--- /dev/null
+++ b/ci_tools/check_python_capitalisation.py
@@ -0,0 +1,75 @@
+""" Look for instances of Python which are not capitalised and report errors
+"""
+import argparse
+import json
+import os
+import re
+import sys
+
+from annotation_helpers import locate_code_blocks, is_text, print_to_string
+
+if __name__ == '__main__':
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('output', metavar='output', type=str,
+                            help='File where the markdown output will be printed')
+    parser.add_argument('files', nargs='*', help='Files to be parsed')
+    p_args = parser.parse_args()
+
+    files = p_args.files
+
+    python_regex = re.compile(r'[^a-zA-Z]python[^a-zA-Z]')
+
+    annotations = []
+    output = {}
+    for f in files:
+        with open(f, 'r', encoding='utf-8') as read:
+            lines = read.readlines()
+        code_blocks = locate_code_blocks(lines)
+        for line_num,l in enumerate(lines,1):
+            idx = 0
+            n = len(l)
+            while idx < n:
+                py_match = python_regex.search(l, idx)
+                if py_match:
+                    start = py_match.start()+1
+                    end = py_match.end()-1
+                    if is_text(l, start, end, line_num, code_blocks):
+                        annotations.append({
+                            "annotation_level":"failure",
+                            "start_line":line_num,
+                            "end_line":line_num,
+                            "start_column":start,
+                            "end_column":end,
+                            "path":f,
+                            "message": "`python` should be capitalised."
+                        })
+                        if f not in output:
+                            output[f] = []
+                            print_to_string(f"Python should be capitalised in the file {f} at the following positions:", text=output[f])
+                        print_to_string(f"- Line {line_num}, Columns {start}-{end}" , text = output[f])
+                    idx = end
+                else:
+                    idx = n
+        if f in output:
+            print_to_string("", text = output[f])
+
+    # Temporary if to be removed when spelling test outputs errors
+    if os.path.exists('test_json_result.json'):
+        with open('test_json_result.json', mode='r', encoding="utf-8") as json_file:
+            messages = json.load(json_file)
+    else:
+        messages = {'summary':''}
+    if annotations:
+        messages['summary'] += "# Python should be capitalised\n"
+        messages.setdefault('annotations', []).extend(annotations)
+    with open(p_args.output, mode='a', encoding="utf-8") as md_file:
+        for l in output.values():
+            text = ''.join(l)
+            md_file.write(text)
+            messages['summary'] += text
+    with open('test_json_result.json', mode='w', encoding="utf-8") as json_file:
+        json.dump(messages, json_file)
+
+    if annotations:
+        sys.exit(1)
diff --git a/ci_tools/check_slots.py b/ci_tools/check_slots.py
index cd19e9ef70..a5fc03ae1f 100644
--- a/ci_tools/check_slots.py
+++ b/ci_tools/check_slots.py
@@ -5,105 +5,185 @@
 import inspect
 import os
 import sys
+import json
 from pyccel import ast
 from pyccel.ast.basic import Basic, PyccelAstNode, ScopedNode
 
-parser = argparse.ArgumentParser(description='Check that all new lines in the python files in the pyccel/ code folder are used in the tests')
-parser.add_argument('output', metavar='output', type=str,
-                        help='File where the markdown output will be printed')
-
-args = parser.parse_args()
-
-# Get ast modules
-ast_folder = os.path.dirname(ast.__file__)
-ast_modules = [mod[:-3] for mod in os.listdir(ast_folder) if mod != '__init__.py' and mod.endswith('.py')]
-
-# Prepare error collection
-missing_all = []
-non_alphabetical_all = []
-missing_slots = []
-overridden_slots = []
-missing_attribute_nodes = []
-missing_from_all = []
-
-for mod_name in ast_modules:
-    mod = importlib.import_module('pyccel.ast.'+mod_name)
-    all_attr = getattr(mod, '__all__', None)
-    if all_attr:
-        sorted_all = list(all_attr)
-        sorted_all.sort()
-        if sorted_all != list(all_attr):
-            non_alphabetical_all.append(mod_name)
-    else:
-        missing_all.append(mod_name)
-
-    classes = inspect.getmembers(mod, inspect.isclass)
-    for cls_name, cls_obj in classes:
-        if inspect.getmodule(cls_obj) is not mod:
-            continue
-        super_classes = cls_obj.mro()[1:]
-        if '__slots__' not in cls_obj.__dict__:
-            missing_slots.append(f"{mod_name}.{cls_name}")
+def extract_dict_elements(input_dict):
+    """
+    Write a dictionary as a dictionary compatible with GitHub's check runs output.
+
+    Extract specific elements from the input dictionary based on the given keys and returns a new dictionary.
+
+    Parameters
+    ----------
+    input_dict : dict
+        The input dictionary from which elements will be extracted.
+
+    Returns
+    -------
+    dict
+        A new dictionary containing the extracted elements.
+    """
+    output_dict = {'title':"Pyccel_lint", 'summary':"## Check Slots:\n\n",'annotations':[]}
+    for values in input_dict.items():
+        value_list = values[-1]
+        if value_list:
+            output_dict['summary'] += f"### {value_list[0]['title']} :\n\n"
+            output_dict['summary'] += "\n\n".join(f"-  {value['message']}" for value in value_list)
+            output_dict['summary'] += "\n\n"
+            output_dict['annotations'].extend(value['annotations'] for value in value_list)
+    return output_dict
+
+def fill_dictionary(title, message, file, start_line, end_line, annotation_level, annotation_msg):
+    """
+    Fill a dictionary with the provided data.
+
+    Fill a dictionary with the provided data to describe an annotation on a GitHub
+    check run.
+
+    Parameters
+    ----------
+    title : str
+        The title of the check run output.
+
+    message : str
+        A description of the output.
+
+    file : str
+        The file to be annotated.
+
+    start_line : int
+        The line at the start of the annotation.
+
+    end_line : int
+        The line at the end of the annotation.
+
+    annotation_level : str
+        The level of the anntoation [failure/warning].
+
+    annotation_msg : str
+        The message in the annotation.
+
+    Returns
+    -------
+    dict
+        A dictionary filled with the provided data.
+    """
+    index = file.find("site-packages")
+    filled_dict = {
+        'title':title,
+        'message':f"`{message}`",
+        'annotations':{
+            'path':file[index + len("site-packages") + 1:],
+            'start_line':start_line,
+            'end_line':end_line,
+            'annotation_level':annotation_level,
+            'message':annotation_msg
+        }
+    }
+    return filled_dict
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description='Check that all new lines in the python files in the pyccel/ code folder are used in the tests')
+    parser.add_argument('output', metavar='output', type=str,
+                            help='File where the markdown output will be printed')
+
+    args = parser.parse_args()
+
+    # Get ast modules
+    ast_folder = os.path.dirname(ast.__file__)
+    ast_modules = [mod[:-3] for mod in os.listdir(ast_folder) if mod != '__init__.py' and mod.endswith('.py')]
+
+    # Prepare error collection
+    missing_all = []
+    non_alphabetical_all = []
+    missing_slots = []
+    overridden_slots = []
+    missing_attribute_nodes = []
+    missing_from_all = []
+
+    error_collection = {
+        'missing_all':[],
+        'non_alphabetical_all':[],
+        'missing_slots':[],
+        'overridden_slots':[],
+        'missing_attribute_nodes':[],
+        'missing_from_all':[]
+    }
+
+    for mod_name in ast_modules:
+        mod = importlib.import_module('pyccel.ast.'+mod_name)
+        all_attr = getattr(mod, '__all__', None)
+        if all_attr:
+            sorted_all = list(all_attr)
+            sorted_all.sort()
+            if sorted_all != list(all_attr):
+                lines = inspect.getsource(mod).splitlines()
+                start_line = -1
+                end_line = -1
+                for line_num, line in enumerate(lines):
+                    if '__all__' in line:
+                        start_line = line_num + 1
+                        while ')' not in line:
+                            line_num += 1
+                            line = lines[line_num]
+                            end_line = line_num + 1
+                        error_collection['non_alphabetical_all'].append(fill_dictionary("Non-alphabetical `__all__`", f"pyccel.ast.{mod_name}",
+                            inspect.getfile(mod), start_line, end_line, "warning", f"Sort the __all__ attribute of `{mod_name}`"))
+                        break
         else:
-            slots = cls_obj.__slots__
-            for c in super_classes:
-                if '__slots__' not in c.__dict__:
-                    continue
-                elif any(s in slots for s in c.__slots__):
-                    overridden_slots.append(f'Slot values are overwritten between `{mod_name}.{cls_name}` and `{c.__name__}`')
-
-        if Basic in super_classes:
-            if cls_obj not in (PyccelAstNode, ScopedNode) and not isinstance(cls_obj._attribute_nodes, tuple): #pylint: disable=W0212
-                missing_attribute_nodes.append(f"{mod_name}.{cls_name}")
-
-        if all_attr and cls_name not in all_attr:
-            missing_from_all.append(f"{mod_name}.{cls_name}")
-
-print("Missing __all__")
-print(missing_all)
-print("__all__ non-alphabetical")
-print(non_alphabetical_all)
-print("Missing __slots__")
-print(missing_slots)
-print("Missing _attribute_nodes")
-print(missing_attribute_nodes)
-print("Not in __all__")
-print(missing_from_all)
-print("Misused slots")
-print(overridden_slots)
-
-with open(args.output, "w", encoding="utf-8") as out:
-    # Report error
-    if missing_all:
-        print("## Missing `__all__`", file=out)
-        for f in missing_all:
-            print(f"-   `pyccel.ast.{f}`", file=out)
-    if non_alphabetical_all:
-        print("## Non-alphabetical `__all__`", file=out)
-        for f in non_alphabetical_all:
-            print(f"-   `pyccel.ast.{f}`", file=out)
-    if missing_from_all:
-        print("## Classes missing from `__all__`", file=out)
-        for f in missing_from_all:
-            print(f"-   `pyccel.ast.{f}`", file=out)
-    if missing_slots:
-        print("## Classes with no `__slots__`", file=out)
-        for f in missing_slots:
-            print(f"-   `pyccel.ast.{f}`", file=out)
-    if missing_attribute_nodes:
-        print("## Classes with no `_attribute_nodes`", file=out)
-        for f in missing_attribute_nodes:
-            print(f"-   `pyccel.ast.{f}`", file=out)
-    if overridden_slots:
-        print("## Misused slots", file=out)
-        for o in overridden_slots:
-            print("-  ", o, file=out)
-
-failure = (bool(missing_all) or # bool(non_alphabetical_all) or
-          bool(missing_slots) or bool(missing_attribute_nodes) or
-          bool(overridden_slots))
-
-if failure:
-    sys.exit(1)
-else:
-    sys.exit(0)
+            error_collection['missing_all'].append(fill_dictionary("Missing `__all__`", f"pyccel.ast.{mod_name}",
+                inspect.getfile(mod), 1, 1, "failure", f"Missing __all__ attribute in: `{mod_name}`"))
+
+        classes = inspect.getmembers(mod, inspect.isclass)
+        for cls_name, cls_obj in classes:
+            if inspect.getmodule(cls_obj) is not mod:
+                continue
+            super_classes = cls_obj.mro()[1:]
+            if '__slots__' not in cls_obj.__dict__:
+                sourceline = inspect.getsourcelines(cls_obj)[1]
+                error_collection['missing_slots'].append(fill_dictionary("Classes with no `__slots__`", f"pyccel.ast.{mod_name}.{cls_name}",
+                    inspect.getfile(mod), sourceline, sourceline, "failure", f"`{mod_name}.{cls_name}` class with no `__slots__`"))
+            else:
+                slots = cls_obj.__slots__
+                for c in super_classes:
+                    if '__slots__' not in c.__dict__:
+                        continue
+                    elif any(s in slots for s in c.__slots__):
+                        sourceline = inspect.getsourcelines(cls_obj)[1]
+                        error_collection['overridden_slots'].append(fill_dictionary("Overwritten slot values", f"pyccel.ast.{mod_name}.{cls_name}",
+                            inspect.getfile(mod), sourceline, sourceline, "failure", f"Slot values are overwritten between `{mod_name}.{cls_name}` and `{c.__name__}`"))
+
+            if Basic in super_classes:
+                if cls_obj not in (PyccelAstNode, ScopedNode) and not isinstance(cls_obj._attribute_nodes, tuple): #pylint: disable=W0212
+                    sourceline = inspect.getsourcelines(cls_obj)[1]
+                    error_collection['missing_attribute_nodes'].append(fill_dictionary("Classes with no `_attribute_nodes`", f"pyccel.ast.{mod_name}.{cls_name}",
+                            inspect.getfile(mod), sourceline, sourceline, "failure", f"Missing attribute nodes in : `{mod_name}.{cls_name}`"))
+
+            if all_attr and cls_name not in all_attr:
+                sourceline = inspect.getsourcelines(cls_obj)[1]
+                error_collection['missing_from_all'].append(fill_dictionary("Classes missing from `__all__`", f"pyccel.ast.{mod_name}.{cls_name}",
+                    inspect.getfile(mod), sourceline, sourceline, "failure", f"`{mod_name}.{cls_name}` is missing from `__all__`"))
+
+    messages = extract_dict_elements(error_collection)
+    if not messages['annotations']:
+        messages['summary'] = "Check Slots\n\n**Success**:The operation was successfully completed. All necessary tasks have been executed without any errors or warnings.\n\n"
+        messages.pop('annotations')
+    with open('test_json_result.json', mode='w', encoding="utf-8") as json_file:
+        json.dump(messages, json_file)
+
+    with open(args.output, "w", encoding="utf-8") as md_file:
+        # Report error
+        md_file.write("# " + messages['title'] + '\n\n')
+        md_file.write(messages['summary'])
+
+    failure = (bool(error_collection['missing_all']) or # bool(error_collection['non_alphabetical_all']) or
+              bool(error_collection['missing_slots']) or bool(error_collection['missing_attribute_nodes']) or
+              bool(error_collection['overridden_slots']))
+
+
+    if failure:
+        sys.exit(1)
+    else:
+        sys.exit(0)
diff --git a/ci_tools/complete_check_run.py b/ci_tools/complete_check_run.py
new file mode 100644
index 0000000000..5b48916be3
--- /dev/null
+++ b/ci_tools/complete_check_run.py
@@ -0,0 +1,41 @@
+""" Script that can be used to calculate the final combined status from results of multiple stages.
+"""
+import os
+import sys
+from bot_tools.bot_funcs import Bot
+
+def get_final_status(statuses : set):
+    """
+    Get the final status from results of each stage.
+
+    Combine the status output of each stage of the
+    test to determine the final combined status.
+
+    Parameters
+    ----------
+    statuses : set
+        The status of each stage.
+
+    Returns
+    -------
+    str
+        The final status.
+    """
+    statuses.discard('skipped')
+    if len(statuses) == 0:
+        return 'cancelled'
+
+    elif len(statuses) == 1:
+        return statuses.pop()
+
+    statuses.discard('success')
+    if len(statuses) == 1:
+        return statuses.pop()
+
+    print(statuses)
+
+    return statuses.pop()
+
+if __name__ == '__main__':
+    bot = Bot(pr_id = os.environ["PR_ID"], check_run_id = os.environ["CHECK_RUN_ID"], commit = os.environ["COMMIT"])
+    bot.post_completed(get_final_status(set(sys.argv[1:])))
diff --git a/ci_tools/coverage_analysis_tools.py b/ci_tools/coverage_analysis_tools.py
index cde470d44f..e5b7597bc3 100644
--- a/ci_tools/coverage_analysis_tools.py
+++ b/ci_tools/coverage_analysis_tools.py
@@ -1,31 +1,32 @@
 """ Functions for comparing coverage output and git diff output
 """
-import os
 import sys
 import defusedxml.ElementTree as ET
 
 def get_untested_lines(coverage_filename):
     """
+    Get all untested lines from a coverage output.
+
     Parse a coverage xml file and return a dictionary containing the files and lines
-    which are untested
+    which are untested.
 
     Parameters
     ----------
     coverage_filename : str
-        The name of the xml file containing the coverage information
+        The name of the xml file containing the coverage information.
 
     Returns
     -------
     no_coverage : dict
             A dictionary whose keys are the files in pyccel
             and whose values are lists containing the line numbers
-            where coverage is lacking in that file
+            where coverage is lacking in that file.
     content_lines : dict
             A dictionary whose keys are the files in pyccel
             and whose values are lists containing the line numbers
             where a python command starts (this excludes comments,
             empty lines, and lines which are continuations of
-            previous lines)
+            previous lines).
     """
     tree = ET.parse(coverage_filename)
     root = tree.getroot()
@@ -38,8 +39,8 @@ def get_untested_lines(coverage_filename):
         lines = f.findall('lines')[0].findall('line')
         all_lines = [int(l.attrib['number']) for l in lines]
         untested_lines = [int(l.attrib['number']) for l in lines if l.attrib['hits'] == "0"]
-        no_coverage[os.path.join('pyccel',filename)] = untested_lines
-        content_lines[os.path.join('pyccel',filename)] = all_lines
+        no_coverage[filename] = untested_lines
+        content_lines[filename] = all_lines
 
     return no_coverage, content_lines
 
@@ -85,6 +86,8 @@ def compare_coverage_to_diff(coverage, diff):
 
 def allow_untested_debug_code(untested):
     """
+    Remove `str` and `repr` functions from dictionary of untested lines.
+
     Takes a dictionary describing untested lines and returns an
     equivalent dictionary without lines designed to print a class
     (should only be used for debugging).
@@ -159,54 +162,110 @@ def allow_untested_error_calls(untested):
 
     return reduced_untested
 
-def print_markdown_summary(untested, content_lines, commit, output):
+def print_markdown_summary(untested, commit, output, repo):
     """
-    Print the results neatly in markdown in a provided file
+    Print the results neatly in markdown in a provided file.
+
+    Print the results neatly in markdown in a provided file such that they can
+    be printed in a GitHub output file.
 
     Parameters
     ----------
-    untested : dict
-        Dictionary whose keys are the files in pyccel with untested
-        lines which have been added in this branch and whose values
-        are lists containing the line numbers where coverage is
-        lacking in that file
-    content_lines : dict
-        Dictionary whose keys are the files in pyccel and whose
-        values are lists containing the line numbers where python
-        commands begin
+    untested : list of dict
+        A list of dictionaries describing all lines with unacceptable coverage.
     commit : str
-        The commit being tested
+        The commit being tested.
     output : str
-        The file where the markdown summary should be printed
+        The file where the markdown summary should be printed.
+    repo : str
+        The repository where the pull request can be found.
     """
     if len(untested) == 0:
         md_string = "## Congratulations! All new python code in the pyccel package is fully tested! :tada:"
     else:
         md_string = "## Warning! The new code is not run\n"
-        for f, lines in untested.items():
-            md_string += f"### {f}\n"
-            line_indices = content_lines[f]
-            n_code_lines = len(line_indices)
-            n_untested = len(lines)
-            i = 0
-            while i < n_untested:
-                start_line = lines[i]
-                j = line_indices.index(start_line)
-                while j < n_code_lines and i < n_untested and lines[i] == line_indices[j]:
-                    i+=1
-                    j+=1
-                if j < n_code_lines-1:
-                    end_line = line_indices[j]-1
-                else:
-                    end_line = line_indices[j]
-                md_string += "https://github.com/pyccel/pyccel/blob/"+commit+"/"+f+f"#L{start_line}-L{end_line}\n"
+        current_file = None
+        for c in untested:
+            f = c['path']
+            if f!= current_file:
+                md_string += f"### {f}\n"
+                current_file = f
+            start_line = c.get('start_line', c['line'])
+            md_string += f"https://github.com/{repo}/blob/{commit}/{f}#L{start_line}-L{c['line']}\n"
 
     with open(output, "a", encoding="utf-8") as out_file:
         print(md_string, file=out_file)
 
+def get_json_summary(untested, content_lines, existing_comments):
+    """
+    Print the results neatly in json in a provided file.
+
+    Print the results neatly in json in a provided file such that
+    each error can be described by a GitHub annotation.
+
+    Parameters
+    ----------
+    untested : dict
+        Dictionary whose keys are the files in pyccel with untested
+        lines which have been added in this branch and whose values
+        are lists containing the line numbers where coverage is
+        lacking in that file.
+    content_lines : dict
+        Dictionary whose keys are the files in pyccel and whose
+        values are lists containing the line numbers where python
+        commands begin.
+    existing_comments : list of dict
+        A list describing all comments previously left about the
+        coverage results.
+
+    Returns
+    -------
+    old_comments : list of dict
+        The coverage issues which were present before this commit and
+        had already been commented on.
+    new_comments : list of dict
+        The coverage issues which were not present before this commit
+        and have never been commented on.
+    fixed_comments : list of dict
+        The coverage issues which were present before this commit and
+        had already been commented on but are no longer present.
+    """
+    message = "This code isn't tested. Please can you take a look"
+    new_comments = []
+    old_comments = []
+    fixed_comments = existing_comments.copy()
+    for f, lines in untested.items():
+        line_indices = content_lines[f]
+        n_code_lines = len(line_indices)
+        n_untested = len(lines)
+        i = 0
+        while i < n_untested:
+            start_line = lines[i]
+            j = line_indices.index(start_line)
+            while j < n_code_lines and i < n_untested and lines[i] == line_indices[j]:
+                i+=1
+                j+=1
+            if j < n_code_lines-1:
+                end_line = line_indices[j]-1
+            else:
+                end_line = line_indices[j]
+            output = {'path':f, 'line':end_line, 'body':message}
+            if start_line != end_line:
+                output['start_line'] = start_line
+            if (f,end_line) in existing_comments:
+                old_comments.append(output)
+                fixed_comments.pop((f,end_line))
+            else:
+                new_comments.append(output)
+
+    return old_comments, new_comments, fixed_comments
+
 def show_results(untested):
     """
-    Print the results and fail if coverage is lacking
+    Print the results and fail if coverage is lacking.
+
+    Print a list of all untested lines and exit the program
+    with exit code 1 if the coverage is incomplete.
 
     Parameters
     ----------
@@ -214,10 +273,76 @@ def show_results(untested):
         Dictionary whose keys are the files in pyccel with untested
         lines which have been added in this branch and whose values
         are lists containing the line numbers where coverage is
-        lacking in that file
+        lacking in that file.
     """
     for f, lines in untested.items():
         print(f"In file {f} the following lines are untested : {lines}")
 
     if len(untested) != 0:
         sys.exit(1)
+
+def check_if_coverage_ignored(comment_json, existing_comments):
+    """
+    Check if a reply was left indicating that the coverage can be ignored.
+
+    For a given coverage problem discovered on a previous run, check if a
+    reply to the comment was left indicating that the coverage can be
+    ignored.
+
+    Parameters
+    ----------
+    comment_json : dict
+        A dictionary describing a coverage problem which was found on a
+        previous run.
+
+    existing_comments : dict
+        A dictionary whose keys are a tuple containing the file and the
+        relevant line in the current version of the code, and whose values
+        are a list of comments and replies previously left on this blob.
+
+    Returns
+    -------
+    bool
+        True if the coverage issue should be ignored, false otherwise.
+    """
+    key = (comment_json['path'], comment_json['line'])
+    comment = existing_comments[key]
+    print(comment)
+    return any('/bot accept' in c['body'] for c in comment)
+
+def evaluate_success(old_comments, new_comments, existing_comments):
+    """
+    Determine if the coverage check was successful.
+
+    Use the old comments, the new comments, and any replies to old comments
+    to determine whether the coverage check passes. The check passes if
+    there are no coverage issues, or if all the coverage issues are handled
+    via replies beginning with "/bot accept"
+
+    Parameters
+    ----------
+    old_comments : list of dict
+        A list of dictionaries describing any coverage problems which were
+        already found on a previous run.
+
+    new_comments : list of dict
+        A list of dictionaries describing any coverage problems which were
+        discovered during this run.
+
+    existing_comments : dict
+        A dictionary whose keys are a tuple containing the file and the
+        relevant line in the current version of the code, and whose values
+        are a list of comments and replies previously left on this blob.
+
+    Returns
+    -------
+    bool
+        True if the test succeeded, false otherwise.
+    """
+    if new_comments:
+        return False
+
+    if len(old_comments) == 0:
+        return True
+
+    return all(check_if_coverage_ignored(r, existing_comments) for r in old_comments)
diff --git a/ci_tools/fetch_artifacts.py b/ci_tools/fetch_artifacts.py
new file mode 100644
index 0000000000..baed004d23
--- /dev/null
+++ b/ci_tools/fetch_artifacts.py
@@ -0,0 +1,22 @@
+""" Script to find all artifacts that can be passed to the coverage test.
+"""
+import os
+import shutil
+import subprocess
+import sys
+from bot_tools.github_api_interactions import GitHubAPIInteractions
+
+if __name__ == '__main__':
+    artifact_urls = sys.argv[1:]
+    file = '.coverage'
+
+    GAI = GitHubAPIInteractions()
+    for i,url in enumerate(artifact_urls):
+        GAI.download_artifact('artifact.zip', url)
+        unzip = shutil.which('unzip')
+        with subprocess.Popen([unzip, 'artifact.zip']) as p:
+            _, err = p.communicate()
+        if err:
+            print(err)
+        os.remove('artifact.zip')
+        shutil.move(file, f"{file}.{i}")
diff --git a/ci_tools/list_docs_tovalidate.py b/ci_tools/list_docs_tovalidate.py
index 4e66f9e953..7d4b235b7f 100644
--- a/ci_tools/list_docs_tovalidate.py
+++ b/ci_tools/list_docs_tovalidate.py
@@ -45,52 +45,61 @@ def should_ignore(name):
                         help='Output of git diff between PR branch and base branch')
     parser.add_argument('output', metavar='output', type=str,
                         help='File to save the output to')
+    parser.add_argument('ci_output', metavar='ci_output', type=str,
+                        help='File to save the output to for the ci_tools folder')
     args = parser.parse_args()
 
     results = get_diff_as_json(args.gitdiff)
-    changes = {}
+    pkg_changes = {}
+    ci_changes = {}
     for file, upds in results.items():
         filepath = PurePath(file)
-        if filepath.parts[0] == 'pyccel' and filepath.suffix == '.py':
+        if filepath.parts[0] != 'tests' and filepath.suffix == '.py':
+            if filepath.parts[0] == 'ci_tools':
+                changes = ci_changes
+            else:
+                changes = pkg_changes
             for line_no in upds['addition']:
                 if file in changes:
                     changes[file].append(int(line_no))
                 else:
                     changes[file] = [int(line_no)]
 
-    for file, line_nos in changes.items():
-        with open(file, 'r', encoding="utf-8") as f:
-            tree = ast.parse(f.read())
-        # The prefix variable stores the absolute dotted prefix of all
-        # objects in the file.
-        # for example: the objects in the file pyccel/ast/core.py will
-        # have the prefix pyccel.ast.core
-        prefix = '.'.join(PurePath(file).with_suffix('').parts)
+    for changes, output in zip([pkg_changes, ci_changes], [args.output, args.ci_output]):
+        with open(output, 'w', encoding="utf-8") as f:
+            for file, line_nos in changes.items():
+                with open(file, 'r', encoding="utf-8") as ast_file:
+                    tree = ast.parse(ast_file.read())
+                # The prefix variable stores the absolute dotted prefix of all
+                # objects in the file.
+                # for example: the objects in the file pyccel/ast/core.py will
+                # have the prefix pyccel.ast.core
+                prefix = '.'.join(PurePath(file).with_suffix('').parts)
 
-        objects = []
-        to_visit = list(ast.iter_child_nodes(tree))
-        for node in to_visit:
-            # This loop walks the ast and explores all objects
-            # present in the file.
-            # If the object is an instance of a FunctionDef or
-            # a ClassDef, a check is performed to see if any of
-            # the updated lines are present within the object.
-            # Additionally, The name of all objects present
-            # inside a function or a class is updated to include
-            # the name of the parent object
-            if isinstance(node, (FunctionDef, ClassDef)):
-                if should_ignore('.'.join([prefix, node.name])):
-                    continue
-                if any((node.lineno <= x <= node.end_lineno
-                        for x in line_nos)):
-                    objects.append('.'.join([prefix, node.name]))
-                if isinstance(node, ClassDef):
-                    obj_pref = node.name
-                    for child in node.body:
-                        if isinstance(child, (FunctionDef, ClassDef)):
-                            child.name = '.'.join([obj_pref, child.name])
-                            to_visit.append(child)
+                objects = []
+                to_visit = list(ast.iter_child_nodes(tree))
+                for node in to_visit:
+                    print(node)
+                    # This loop walks the ast and explores all objects
+                    # present in the file.
+                    # If the object is an instance of a FunctionDef or
+                    # a ClassDef, a check is performed to see if any of
+                    # the updated lines are present within the object.
+                    # Additionally, The name of all objects present
+                    # inside a function or a class is updated to include
+                    # the name of the parent object
+                    if isinstance(node, (FunctionDef, ClassDef)):
+                        if should_ignore('.'.join([prefix, node.name])):
+                            continue
+                        if any((node.lineno <= x <= node.end_lineno
+                                for x in line_nos)):
+                            objects.append('.'.join([prefix, node.name]))
+                        if isinstance(node, ClassDef):
+                            obj_pref = node.name
+                            for child in node.body:
+                                if isinstance(child, (FunctionDef, ClassDef)):
+                                    child.name = '.'.join([obj_pref, child.name])
+                                    to_visit.append(child)
 
-        with open(args.output, 'a', encoding="utf-8") as f:
-            for obj in objects:
-                print(obj, file=f)
+                for obj in objects:
+                    print(obj, file=f)
diff --git a/ci_tools/parse_pylint_output.py b/ci_tools/parse_pylint_output.py
new file mode 100644
index 0000000000..6b098b8690
--- /dev/null
+++ b/ci_tools/parse_pylint_output.py
@@ -0,0 +1,64 @@
+""" Parse pylint output and format the output for neat bot results.
+"""
+import argparse
+from collections import namedtuple
+import json
+import sys
+
+PylintMessage = namedtuple('PylintMessage', ['file','line', 'position', 'message'])
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description='Parse pylint output and format the output for neat bot results.')
+    parser.add_argument('pylint', type=str,
+                            help='The file containing the pylint output')
+    parser.add_argument('output', metavar='output', type=str,
+                            help='File where the markdown output will be printed')
+    args = parser.parse_args()
+
+    with open(args.pylint, 'r', encoding='utf-8') as p_file:
+        pylint_output = p_file.readlines()
+
+    pylint_output = [l.strip() for l in pylint_output]
+
+    pylint_results = {}
+    idx = 0
+    line = pylint_output[idx]
+    while not all(c=='-' for c in line):
+        if line.startswith('***'):
+            _, key = line.split(' Module ')
+            pylint_results[key] = []
+        else:
+            file, line, start, code, message = line.split(':')
+            pylint_results[key].append(PylintMessage(file, line, start, message.strip()))
+        idx += 1
+        line = pylint_output[idx]
+
+    if pylint_results:
+        output = "# Pylint errors found\n"
+    else:
+        output = "# Success! No pylint errors found\n"
+
+    annotations = []
+    for mod, msgs in pylint_results.items():
+        output += f"## Errors found in module {mod}\n"
+        for m in msgs:
+            output += f"-  On line {m.line} : {m.message}\n"
+            annotations.append({
+                                'path':m.file,
+                                'start_line':int(m.line),
+                                'end_line':int(m.line),
+                                'start_column':int(m.position),
+                                'annotation_level':'failure',
+                                'message':m.message})
+
+    json_data = {'summary': output}
+    if annotations:
+        json_data['annotations'] = annotations
+
+    with open('test_json_result.json', mode='w', encoding="utf-8") as json_file:
+        json.dump(json_data, json_file)
+    with open(args.output, mode='a', encoding="utf-8") as md_file:
+        md_file.write(output)
+
+    if annotations:
+        sys.exit(1)
diff --git a/ci_tools/process_results.py b/ci_tools/process_results.py
index e15d620b84..b4a381c7bc 100644
--- a/ci_tools/process_results.py
+++ b/ci_tools/process_results.py
@@ -1,68 +1,140 @@
 """ Script to process the output of numpydoc validator"
 """
 import argparse
+import json
+import os
 import sys
 
-parser = argparse.ArgumentParser(description='Process the output of numpydoc validator')
-parser.add_argument('report', metavar='report', type=str,
-                        help='Report generated by numpydoc validator')
-parser.add_argument('summary', metavar='summary', type=str,
-                        help='Github step summary')
-args = parser.parse_args()
-
-error_codes = ['GL01', 'GL02', 'GL03', 'GL05', 'GL06', 'GL07', 'GL08', 'GL09',
-               'GL10', 'SS01', 'SS02', 'SS03', 'SS04', 'SS05', 'SS06', 'ES01',
-               'PR01', 'PR02', 'PR03', 'PR04', 'PR05', 'PR06', 'PR07', 'PR08',
-               'PR09', 'PR10', 'RT01', 'RT02', 'RT03', 'RT04', 'RT05', 'YD01',
-               'SA02', 'SA03', 'SA04']
-
-warning_codes = ['EX01', 'SA01']
-
-errors = {}
-warnings = {}
-parsing_errors = []
-with open(args.report, 'r', encoding='utf-8') as f:
-    for line in f:
-        try:
-            file_name, code, msg = line.split(':', maxsplit=2)
+from annotation_helpers import print_to_string, get_code_file_and_lines
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description='Process the output of numpydoc validator')
+    parser.add_argument('report', metavar='report', type=str,
+                            help='Report generated by numpydoc validator')
+    parser.add_argument('summary', metavar='summary', type=str,
+                            help='Github step summary')
+    args = parser.parse_args()
+
+    error_codes = ['GL01', 'GL02', 'GL03', 'GL05', 'GL06', 'GL07', 'GL08', 'GL09',
+                   'GL10', 'SS01', 'SS02', 'SS03', 'SS04', 'SS05', 'SS06', 'ES01',
+                   'PR01', 'PR02', 'PR03', 'PR04', 'PR05', 'PR06', 'PR07', 'PR08',
+                   'PR09', 'PR10', 'RT01', 'RT02', 'RT03', 'RT04', 'RT05', 'YD01',
+                   'SA02', 'SA03', 'SA04']
+
+    warning_codes = ['EX01', 'SA01']
+
+    #pyccel_folder = os.path.abspath('compare')
+    pyccel_folder = '/home/emily/Code/pyccel'
+
+    errors = {}
+    warnings = {}
+    parsing_errors = []
+    annotations = []
+    with open(args.report, 'r', encoding='utf-8') as f:
+        for line in f:
+            try:
+                file_name, code, msg = line.split(':', maxsplit=2)
+            except ValueError:
+                code = 'PARSING_ERROR'
+
             if code in error_codes:
+                level = 'failure'
                 if file_name not in errors:
                     errors[file_name] = [msg]
                 else:
                     errors[file_name].append(msg)
                 parsing_errors.append(line)
             elif code in warning_codes:
+                level = 'warning'
                 if file_name not in warnings:
                     warnings[file_name] = [msg]
                 else:
                     warnings[file_name].append(msg)
             else:
+                level = None
                 parsing_errors.append(line)
-        # This catch is for errors that arise from the line split
-        # when the line does not follow the file:err_code_msg pattern
-        # ie, in the case of parsing errors.
-        except ValueError:
-            parsing_errors.append(line)
+            print(code, msg)
+            if level == 'failure':
+                file, start, end = get_code_file_and_lines(file_name, pyccel_folder = pyccel_folder)
+                if not code.startswith('GL'):
+                    with open(os.path.join(pyccel_folder, file), 'r', encoding='utf-8') as code_file:
+                        lines = code_file.readlines()[start-1:end+2]
+                    lines = [l.strip() for l in lines]
+                    doc_openings = [i for i,l in enumerate(lines) if l.startswith('"""') or l.endswith('"""')]
+                    lines = lines[doc_openings[0]:doc_openings[1]+1]
+                    end = start + doc_openings[1]
+                    start = start + doc_openings[0]
+                    sections = [(l,i) for i,l in enumerate(lines[:-1]) if lines[i+1] != '' and all(c in ('-','=') for c in lines[i+1])]
+                    nsections = len(sections)
+                    if code.startswith('PR'):
+                        changed = False
+                        if msg.startswith('Parameter "'):
+                            _, key, _ = msg.split('"', 2)
+                            try:
+                                start = start + next(i for i,l in enumerate(lines) if l.startswith(key))
+                                end = start
+                                changed = True
+                            except StopIteration:
+                                pass
+                        if not changed:
+                            try:
+                                idx = next(i for i, (sec, l) in enumerate(sections) if sec == 'Parameters')
+                                if idx < nsections-1:
+                                    end = start + sections[idx+1][1] - 1
+                                start += sections[idx][1]
+                            except StopIteration:
+                                pass
+                    if code.startswith('RT') and code != 'RT01':
+                        idx = next(i for i, (sec, l) in enumerate(sections) if sec == 'Returns')
+                        if idx < nsections-1:
+                            end = start + sections[idx+1][1] - 1
+                        start += sections[idx][1]
+                    if code.startswith('SS'):
+                        idx = 0 if any(c != '"' for c in lines[0]) else 1
+                        start += start + idx
+                        end = start
+                annotations.append({
+                    "annotation_level":level,
+                    "start_line":start,
+                    "end_line":end,
+                    "path":file,
+                    "message":msg
+                })
 
-fail = len(errors) > 0 or len(parsing_errors) > 0
+    fail = len(errors) > 0 or len(parsing_errors) > 0
 
-with open(args.summary, 'a', encoding='utf-8') as f:
-    print('# Part 2 : Numpydoc Validation:', file=f)
-    print(f'## {"FAILURE" if fail else "SUCCESS"}!', file=f)
+    summary = []
+
+    print_to_string('# Part 2 : Numpydoc Validation:', text=summary)
+    print_to_string(f'## {"FAILURE" if fail else "SUCCESS"}!', text=summary)
     if fail:
-        print('### ERRORS!', file=f)
+        print_to_string('### ERRORS!', text=summary)
     for file_name, errs in errors.items():
-        print(f'#### {file_name}', file=f)
-        print(''.join(f'- {err}' for err in errs), file=f)
-    if (len(warnings) > 0):
-        print('### WARNINGS!', file=f)
+        print_to_string(f'#### {file_name}', text=summary)
+        print_to_string(''.join(f'- {err}' for err in errs), text=summary)
+    if len(warnings) > 0:
+        print_to_string('### WARNINGS!', text=summary)
     for file_name, warns in warnings.items():
-        print(f'#### {file_name}', file=f)
-        print(''.join(f'- {warn}' for warn in warns), file=f)
-    if (len(parsing_errors) > 0):
-        print('### PARSING ERRORS!', file=f)
+        print_to_string(f'#### {file_name}', text=summary)
+        print_to_string(''.join(f'- {warn}' for warn in warns), text=summary)
+    if len(parsing_errors) > 0:
+        print_to_string('### PARSING ERRORS!', text=summary)
         parsing_errors = ['\n' if 'warn(msg)' in err else err for err in parsing_errors]
-        print(''.join(f'{add_warn}' for add_warn in parsing_errors), file=f)
+        print_to_string(''.join(f'{add_warn}' for add_warn in parsing_errors), text=summary)
+
+    summary_text = '\n'.join(summary)
+
+    with open('test_json_result.json', mode='r', encoding="utf-8") as json_file:
+        messages = json.load(json_file)
+
+    messages['summary'] += '\n\n'+summary_text
+    messages.setdefault('annotations', []).extend(annotations)
+
+    with open(args.summary, 'a', encoding='utf-8') as f:
+        print(summary_text, file=f)
+
+    with open('test_json_result.json', mode='w', encoding="utf-8") as json_file:
+        json.dump(messages, json_file)
 
-sys.exit(fail)
+    sys.exit(fail)
 
diff --git a/ci_tools/setup_check_run.py b/ci_tools/setup_check_run.py
new file mode 100644
index 0000000000..f04eca5c94
--- /dev/null
+++ b/ci_tools/setup_check_run.py
@@ -0,0 +1,34 @@
+""" Script called to create a check run for a job in a workflow dispatch.
+"""
+import json
+import os
+from bot_tools.bot_funcs import Bot
+
+if __name__ == '__main__':
+    input_check_run_id = os.environ["GITHUB_CHECK_RUN_ID"]
+
+    bot = Bot(pr_id = os.environ.get('PR_ID', 0), check_run_id = input_check_run_id, commit = os.environ["COMMIT"])
+
+    if input_check_run_id == "":
+        # Parse event payload from $GITHUB_EVENT_PATH variable
+        # (documented here : https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables)
+        # The contents of this json file depend on the triggering event and are
+        # described here :  https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads
+        with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as event_file:
+            event = json.load(event_file)
+        workflow_file = event["workflow"]
+        test_key = os.path.splitext(os.path.basename(workflow_file))[0]
+        posted = bot.create_in_progress_check_run(test_key)
+    else:
+        posted = bot.post_in_progress(int(os.environ['GITHUB_RUN_ATTEMPT']) > 1)
+
+    run_id = posted['id']
+    pr_id = bot.get_pr_id()
+    sha = posted['head_sha']
+
+    print(f"check_run_id={run_id}", sep='')
+    print(os.environ["GITHUB_ENV"])
+    with open(os.environ["GITHUB_ENV"], "a", encoding='utf-8') as f:
+        print(f"CHECK_RUN_ID={run_id}", sep='', file=f)
+        print(f"PR_ID={pr_id}", sep='', file=f)
+        print(f"HEAD_SHA={sha}", sep='', file=f)
diff --git a/ci_tools/summarise_doccoverage.py b/ci_tools/summarise_doccoverage.py
index 42925f075f..c97abcf54f 100644
--- a/ci_tools/summarise_doccoverage.py
+++ b/ci_tools/summarise_doccoverage.py
@@ -1,92 +1,131 @@
 """ Script to check if documentation coverage has decreased
 """
 import argparse
+import json
+import os
 import sys
 
 from list_docs_tovalidate import should_ignore
+from annotation_helpers import print_to_string, get_code_file_and_lines
 
-parser = argparse.ArgumentParser(description='Check doc coverage change')
-parser.add_argument('base', metavar='head_cov', type=str,
-                        help='File containing the coverage of the head branch')
-parser.add_argument('compare', metavar='base_cov', type=str,
-                        help='File containing the coverage of the base branch')
-parser.add_argument('output', metavar='output', type=str,
-                        help='File where the markdown output will be printed')
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description='Check doc coverage change')
+    parser.add_argument('base', metavar='head_cov', type=str,
+                            help='File containing the coverage of the head branch')
+    parser.add_argument('compare', metavar='base_cov', type=str,
+                            help='File containing the coverage of the base branch')
+    parser.add_argument('output', metavar='output', type=str,
+                            help='File where the markdown output will be printed')
 
-args = parser.parse_args()
+    args = parser.parse_args()
 
-results = {}
-for branch_file in [args.base, args.compare]:
-    branch = branch_file[:-4]
-    with open(branch_file, encoding="utf-8") as f:
-        lines = f.readlines()
+    results = {}
+    for branch_file in [args.base, args.compare]:
+        branch = branch_file[:-4]
+        with open(branch_file, encoding="utf-8") as f:
+            lines = f.readlines()
 
-    results[branch + '_summary'] = ''.join(lines[-3:])
-    results[branch + '_no_mod'] = set()
-    results[branch + '_no_obj'] = set()
-    lines = [l for l in lines if l.startswith('File: ') or l.startswith(' - ')]
-    n = len(lines)
-    i = 0
-    while i < n:
-        modname = lines[i].split()[1].strip('"')[:-3].replace('/','.').split(f'.{branch}.', 1)[1]
-        i+=1
-        while i<n and lines[i].startswith(' - '):
-            if lines[i].startswith(' - No module docstring'):
-                results[branch + '_no_mod'].add(modname)
-            else:
-                objname = lines[i].split()[-1].strip('`')
-                if not should_ignore(objname):
-                    results[branch + '_no_obj'].add('.'.join([modname,objname]))
-            i += 1
+        results[branch + '_summary'] = ''.join(lines[-3:])
+        results[branch + '_no_mod'] = set()
+        results[branch + '_no_obj'] = {}
+        lines = [l for l in lines if l.startswith('File: ') or l.startswith(' - ')]
+        n = len(lines)
+        i = 0
+        while i < n:
+            modname = lines[i].split()[1].strip('"')[:-3].replace('/','.').split(f'.{branch}.', 1)[1]
+            i+=1
+            while i<n and lines[i].startswith(' - '):
+                if lines[i].startswith(' - No module docstring'):
+                    results[branch + '_no_mod'].add(modname)
+                else:
+                    objname = lines[i].split()[-1].strip('`')
+                    if not should_ignore(objname):
+                        obj_name_parts = objname.split('.')
+                        mod_dict = results[branch + '_no_obj'].setdefault(modname, {})
+                        mod_dict.setdefault(obj_name_parts[0], []).append(obj_name_parts[1:])
+                i += 1
 
-added_mod = [mod for mod in results['compare_no_mod'] if mod not in results['base_no_mod']]
-added_obj = [obj for obj in results['compare_no_obj'] if obj not in results['base_no_obj']]
+    added_mod = [mod for mod in results['compare_no_mod'] if mod not in results['base_no_mod']]
+    added_obj = {(mod, cls): methods for mod, obj in results['compare_no_obj'].items() \
+                                     for cls, methods in obj.items() \
+                                     if methods != results['base_no_obj'].get(mod, {}).get(cls, None)}
 
-if len(added_mod) > 0 or len(added_obj) > 0:
-    print('Failure: Coverage has decreased!')
-    print('Base Branch Summary')
-    print(results['base_summary'])
-    print('Compare Branch Summary')
-    print(results['compare_summary'])
-    if len(added_mod) > 0:
-        print('This pull request added these modules without docstrings:')
-        for mod in added_mod:
-            print(f'\t * {mod}')
-        print()
-    if len(added_obj) > 0:
-        print('This pull request added these objects without docstrings:')
-        for obj in added_obj:
-            print(f'\t * {obj}')
-        print()
-    with open(args.output, "w", encoding="utf-8") as out:
-        print('## Failure: Coverage has decreased!', file=out)
-        print('### Base Branch Summary', file=out)
-        print(results['base_summary'], file=out)
-        print('### Compare Branch Summary', file=out)
-        print(results['compare_summary'], file=out)
+    base_folder = os.path.abspath(args.base[:-4])
+
+    if len(added_mod) > 0 or len(added_obj) > 0:
+        annotations = []
+        summary = []
+        print_to_string('## Failure: Coverage has decreased!', text=summary)
+        print_to_string('### Base Branch Summary', text=summary)
+        print_to_string(results['base_summary'], text=summary)
+        print_to_string('Compare Branch Summary', text=summary)
+        print_to_string(results['compare_summary'], text=summary)
         if len(added_mod) > 0:
-            print('### This pull request added these modules without docstrings:', file=out)
+            print_to_string('### This pull request added these modules without docstrings:', text=summary)
             for idx, mod in enumerate(added_mod):
-                print(f'{idx + 1}. {mod}', file=out)
-            print()
+                print_to_string(f'{idx + 1}. {mod}', text=summary)
+                annotations.append({
+                    "annotation_level":"failure",
+                    "start_line":1,
+                    "end_line":1,
+                    "path":mod.replace('.','/')+'.py',
+                    "message":"Missing module docstring."
+                })
+            print_to_string(text=summary)
         if len(added_obj) > 0:
-            print('### This pull request added these objects without docstrings:', file=out)
-            for idx, obj in enumerate(added_obj):
-                print(f'{idx + 1}. {obj}', file=out)
-            print()
+            print_to_string('### This pull request added these objects without docstrings:', text=summary)
+            idx = 0
+            for (mod, cls), objects in added_obj.items():
+                if [] in objects:
+                    file, start, end = get_code_file_and_lines(cls, base_folder, mod)
+                    print_to_string(f'{idx + 1}.  {mod}.{cls}', text=summary)
+                    idx += 1
+                    annotations.append({
+                        "annotation_level":"failure",
+                        "start_line":start,
+                        "end_line":end,
+                        "path":file,
+                        "message":"Missing docstring."
+                    })
+                for obj in objects:
+                    if obj == []:
+                        continue
+                    obj_name = '.'.join(obj)
+                    file, start, end = get_code_file_and_lines(f"{cls}.{obj_name}", base_folder, mod)
+                    if obj in results['base_no_obj'].get(mod, {}).get(cls, []):
+                        level = 'warning'
+                    else:
+                        level = 'failure'
+                        print_to_string(f'{idx + 1}.  {mod}.{cls}.{obj_name}', text=summary)
+                        idx += 1
+                    annotations.append({
+                        "annotation_level":level,
+                        "start_line":start,
+                        "end_line":end,
+                        "path":file,
+                        "message":"Missing docstring."
+                    })
+            print_to_string(text=summary)
+        summary_text = "\n".join(summary)
+        messages = {'summary' : summary_text,
+                    'annotations': annotations}
+        with open('test_json_result.json', mode='w', encoding="utf-8") as json_file:
+            json.dump(messages, json_file)
+        with open(args.output, "w", encoding="utf-8") as out:
+            print(summary_text, file=out)
 
-    sys.exit(1)
+        sys.exit(1)
 
-else:
-    print('Success!')
-    print('Base Branch Summary')
-    print(results['base_summary'])
-    print('Compare Branch Summary')
-    print(results['compare_summary'])
-    with open(args.output, "w", encoding="utf-8") as out:
-        print('# Part 1:', file=out)
-        print('## Success!', file=out)
-        print('### Base Branch Summary', file=out)
-        print(results['base_summary'], file=out)
-        print('### Compare Branch Summary', file=out)
-        print(results['compare_summary'], file=out)
+    else:
+        summary = []
+        print_to_string('# Part 1:', text=summary)
+        print_to_string('## Success!', text=summary)
+        print_to_string('### Base Branch Summary', text=summary)
+        print_to_string(results['base_summary'], text=summary)
+        print_to_string('### Compare Branch Summary', text=summary)
+        print_to_string(results['compare_summary'], text=summary)
+        summary_text = "\n".join(summary)
+        with open(args.output, "w", encoding="utf-8") as out:
+            print(summary_text, file=out)
+        with open('test_json_result.json', mode='w', encoding="utf-8") as json_file:
+            json.dump({'summary':"# Documentation coverage is complete!"}, json_file)
diff --git a/ci_tools/summarise_pyspelling.py b/ci_tools/summarise_pyspelling.py
index 352ff02734..d50a51a825 100644
--- a/ci_tools/summarise_pyspelling.py
+++ b/ci_tools/summarise_pyspelling.py
@@ -4,59 +4,129 @@
 import difflib
 import os
 import sys
+import json
+import re
 
-parser = argparse.ArgumentParser(description='Create a neat markdown file to summarise the results')
-parser.add_argument('spelling', metavar='diffFile', type=str,
-                        help='File containing the pyspelling output')
-parser.add_argument('output', metavar='output', type=str,
-                        help='File where the markdown output will be printed')
 
-args = parser.parse_args()
+def find_all_words(file_path, search_word):
+    """
+    Find all occurrences of a word in a file.
 
-with open(args.spelling, encoding="utf-8") as f:
-    lines = f.readlines()
+    Find all occurrences of a word in a file and return the line number and column of each occurrence of the search word.
 
-lines = [l.strip() for l in lines[:-1]]
-lines = [l for l in lines if l != 'Misspelled words:' and any(c != '-' for c in l)]
+    Parameters
+    ----------
+    file_path : str
+        The path to the file to search.
+    search_word : str
+        The word to search for.
 
-errors = {}
+    Returns
+    -------
+    list
+        A list of tuples, where each tuple contains the line number and
+        column number of an occurrence of the search word.
+    """
+    results = []
+    regex = re.compile(r"\b" + re.escape(search_word) + r"\b")
 
-n = len(lines)
-i = 0
-while i < n:
-    filename = lines[i].split()[1]
-    i+=1
-    words = set()
-    while i<n and not lines[i].startswith('<htmlcontent>'):
-        words.add(lines[i])
+    with open(file_path, 'r', encoding="utf-8") as file:
+        lines = file.readlines()
+        for line_number, line in enumerate(lines, start=1):
+            matches = regex.finditer(line)
+            for match in matches:
+                column = match.start() + 1
+                results.append((line_number, column))
+
+    if results:
+        return results
+    return None
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description='Create a neat markdown file to summarise the results')
+    parser.add_argument('spelling', metavar='diffFile', type=str,
+                            help='File containing the pyspelling output')
+    parser.add_argument('output', metavar='output', type=str,
+                            help='File where the markdown output will be printed')
+
+    args = parser.parse_args()
+
+    with open(args.spelling, encoding="utf-8") as f:
+        lines = f.readlines()
+
+    lines = [l.strip() for l in lines[:-1]]
+    lines = [l for l in lines if l != 'Misspelled words:' and any(c != '-' for c in l)]
+
+    errors = {}
+    annotations = []
+
+    n = len(lines)
+    i = 0
+    while i < n:
+        filename = lines[i].split()[1]
         i+=1
+        words = set()
+        while i<n and not lines[i].startswith('<htmlcontent>'):
+            words.add(lines[i])
+            i+=1
+
+        if filename in errors:
+            errors[filename].update(words)
+        else:
+            errors[filename] = words
+
+    if errors:
+        all_words = set()
+
+        with open(os.path.join(os.path.dirname(__file__),'..','.dict_custom.txt'), encoding="utf-8") as d:
+            internal_dict = [w.strip() for w in d.readlines()]
+
+        with open(args.output, 'w', encoding="utf-8") as f:
+            print("There are misspelled words", file=f)
+            for name, words in errors.items():
+                print("## `", name, "`", file=f)
+                for w in words:
+                    words_list = find_all_words(os.path.join("..",name.strip(":")), w)
+                    suggestions = difflib.get_close_matches(w, internal_dict)
+                    for line_no, column in words_list:
+                        if suggestions:
+                            msg_cus = f" Misspelled word :  Did you mean {w} -> {suggestions}"
+                        else:
+                            msg_cus = f"Misspelled word {w}"
+                        annotation_1 = {
+                            "path": name,
+                            "start_line": line_no,
+                            "end_line": line_no,
+                            "start_column": column,
+                            "end_column": column + len(w),
+                            "annotation_level": "failure",
+                            "message": msg_cus,
+                            "title": "Misspelled word"
+                        }
+                        annotations.append(annotation_1)
+                    if suggestions:
+                        print("-   ", w, f"  :  Did you mean {w} -> {suggestions}", file=f)
+                    else:
+                        print("-   ", w, file=f)
+                print(file=f)
+                all_words.update(words)
+
+            print("These errors may be due to typos, capitalisation errors, or lack of quotes around code. If this is a false positive please add your word to `.dict_custom.txt`", file=f)
+
+        # Generating a json file for github check runs
+        output_file = 'test_json_result.json'
+        md = ""
 
-    if filename in errors:
-        errors[filename].update(words)
+        with open(args.output, 'r', encoding="utf-8") as f:
+            md = f.read()
+        print(md)
+        json_ouput = {
+            "title":"Misspelling summary ",
+            "summary":md,
+            "annotations": annotations
+        }
+        with open(output_file, 'w', encoding="utf-8") as f:
+            json.dump(json_ouput,f)
+        sys.exit(1)
     else:
-        errors[filename] = words
-
-if errors:
-    all_words = set()
-
-    with open(os.path.join(os.path.dirname(__file__),'..','.dict_custom.txt'), encoding="utf-8") as d:
-        internal_dict = [w.strip() for w in d.readlines()]
-
-    with open(args.output, 'w', encoding="utf-8") as f:
-        print("There are misspelled words", file=f)
-        for name, words in errors.items():
-            print("## `", name, "`", file=f)
-            for w in words:
-                suggestions = difflib.get_close_matches(w, internal_dict)
-                if suggestions:
-                    print("-   ", w, f"  :  Did you mean {w} -> {suggestions}", file=f)
-                else:
-                    print("-   ", w, file=f)
-            print(file=f)
-            all_words.update(words)
-
-        print("These errors may be due to typos, capitalisation errors, or lack of quotes around code. If this is a false positive please add your word to `.dict_custom.txt`", file=f)
-
-    sys.exit(1)
-else:
-    sys.exit(0)
+        sys.exit(0)
diff --git a/developer_docs/how_to_solve_an_issue.md b/developer_docs/how_to_solve_an_issue.md
index 0a6a12ebb4..8c6771cec1 100644
--- a/developer_docs/how_to_solve_an_issue.md
+++ b/developer_docs/how_to_solve_an_issue.md
@@ -6,7 +6,7 @@ This file summarises basic approaches which should allow you to attempt some of
 
 To add a new function:
 
--   Determine the functions in C/Fortran that are equivalent to the function you want to support (ideally aim to support as many arguments as seems feasible, check the python standard for the latest description of the function)
+-   Determine the functions in C/Fortran that are equivalent to the function you want to support (ideally aim to support as many arguments as seems feasible, check the Python standard for the latest description of the function)
 -   Add a class to represent the function. The class should go in the appropriate file in the [ast](../pyccel/ast) folder. This function will probably inherit from [PyccelInternalFunction](../pyccel/ast/internals.py)
 -   Ensure the function is recognised in the semantic stage by adding it to the appropriate dictionary (see the function `builtin_function` and the dictionary `builtin_import_registery` in [ast/utilities.py](../pyccel/ast/utilities.py)
 -   Add the print functions for the 3 languages
@@ -16,5 +16,5 @@ To add a new function:
 
 -   Use the issue description to reproduce the bug
 -   Add a test targeting this specific problem (which fails in the master branch but will pass after your fix)
--   By comparing the original python code and the generated code, try to locate the problematic line and therefore the print function in the corresponding code generation file
+-   By comparing the original Python code and the generated code, try to locate the problematic line and therefore the print function in the corresponding code generation file
 -   Try to fix the problem
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index ce4c3456df..745eaea529 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -1,10 +1,10 @@
-# Contributing to pyccel
+# Contributing to Pyccel
 
-Firstly, thanks for your interest in contributing code to pyccel!
+Firstly, thanks for your interest in contributing code to Pyccel!
 
 We welcome any and all contributions.
 
-There are many ways to help with the pyccel project which are more or less involved.
+There are many ways to help with the Pyccel project which are more or less involved.
 The following is a summary of how you can do this.
 
 ## Asking questions
@@ -19,7 +19,7 @@ Any help answering these questions is appreciated as it helps our users find sol
 ## Reporting issues
 
 When reporting issues please include as much detail as possible about your
-operating system, and python version. Whenever possible, please
+operating system, and Python version. Whenever possible, please
 also include a brief, self-contained code example that demonstrates the problem.
 
 ## Requesting features
@@ -42,10 +42,10 @@ There are many pull requests and reviewing them is a great way to help things ge
 This is also a really good way to get to grips with the code base as you will see examples of many different areas of the code.
 It's incredibly helpful to have pull requests go through as many reviews as possible, to make sure the code change makes sense, is documented, and is efficient and clear.
 As the clarity is subjective, more eyes can only improve the code base.
-The review process is described in the [developer docs](https://github.com/pyccel/pyccel/blob/master/developer_docs/review_process.md), keep an eye out for PRs tagged `needs_initial_review`.
+The review process is described in the [developer docs](https://github.com/pyccel/pyccel/blob/master/developer_docs/review_process.md), keep an eye out for pull requests tagged `needs_initial_review`.
 
 ## Contributing code
 
 Before contributing we strongly recommend you check out the [developer docs](https://github.com/pyccel/pyccel/tree/master/developer_docs).
 We try to flag `good-first-issue`s.
-These are either issues which can be fixed by following the example provided by a similar solution which is already implemented, or issues which only concern one of the pyccel [stages](https://github.com/pyccel/pyccel/blob/master/developer_docs/overview.md).
+These are either issues which can be fixed by following the example provided by a similar solution which is already implemented, or issues which only concern one of the Pyccel [stages](https://github.com/pyccel/pyccel/blob/master/developer_docs/overview.md).
diff --git a/docs/compiler.md b/docs/compiler.md
index d0b9c678ab..ad8092324f 100644
--- a/docs/compiler.md
+++ b/docs/compiler.md
@@ -64,4 +64,7 @@ To avoid these problems, Pyccel ignores Conda paths when searching for compilers
 
 By using the expected compiler, the expected libraries are correctly linked.
 
-Pyccel offers ``--conda-warnings`` flag with options ``off``, ``basic``, and ``verbose`` to control the visibility of Conda-related warnings.
\ No newline at end of file
+Pyccel offers the `--conda-warnings` flag which takes one of the following options : `off`, `basic`, `verbose`.
+This controls the visibility of Conda-related warnings. The default value is `basic` which indicates when folders are ignored.
+The `verbose` option additionally outputs a list of all ignored folders. These folders can be un-ignored using a user-defined compiler file as described above.
+The `off` option suppresses all Conda-related warnings.
diff --git a/docs/decorators.md b/docs/decorators.md
index cf7edb3589..fd9636cdb8 100644
--- a/docs/decorators.md
+++ b/docs/decorators.md
@@ -110,7 +110,7 @@ end module boo
 
 ## Allow negative index
 
-In python negative indexes allow a user to index an array starting from the back (e.g. the index -1 is the
+In Python negative indexes allow a user to index an array starting from the back (e.g. the index -1 is the
 last element of the array). Pyccel recreates this behaviour for literal indexes. However when an array is
 indexed with a variable or an expression, it is impractical (and often impossible) to know at compile time whether the index is
 positive or negative. As a result an if block must be added. This implies a (potentially large) performance
@@ -213,7 +213,7 @@ In Python it is often the case that a function with scalar arguments and a singl
 
 Important note: applying the `elemental` decorator to a function will not make a difference to the C translation of the function definition itself since C doesn't have the elementwise feature. However, Pyccel implements that functionality by calling the function in a `for` loop when an array argument is passed. In the following example, we will use the function `square` where `@elemental` will be useful:
 
-Here is the python code:
+Here is the Python code:
 
 ```python
 from pyccel.decorators import elemental
diff --git a/docs/installation.md b/docs/installation.md
index c74d82e600..e340bd738a 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -170,7 +170,7 @@ os.add_dll_directory('C://ProgramData/chocolatey/lib/mingw/tools/install/mingw64
 
 These commands must be run every time a Python instance is opened which will import a Pyccel-generated library.
 
-If you use Pyccel often and aren't scared of debugging any potential DLL confusion from other libraries. You can use a `.pth` file to run the necessary commands automatically. The location where the `.pth` file should be installed is described in the [python docs](https://docs.python.org/3/library/site.html). Once the site is located you can run:
+If you use Pyccel often and aren't scared of debugging any potential DLL confusion from other libraries. You can use a `.pth` file to run the necessary commands automatically. The location where the `.pth` file should be installed is described in the [Python docs](https://docs.python.org/3/library/site.html). Once the site is located you can run:
 ```sh
 echo "import os; os.add_dll_directory('C://ProgramData/chocolatey/lib/mingw/tools/install/mingw64/lib'); os.add_dll_directory('C://ProgramData/chocolatey/lib/mingw/tools/install/mingw64/bin')" > $SITE_PATH/dll_path.pth
 ```
@@ -214,7 +214,7 @@ for a system-wide installation.
     pip3 install --user -e ".[test]"
     ```
 
-this will install a _python_ library **Pyccel** and a _binary_ called **`pyccel`**.
+this will install a _Python_ library **Pyccel** and a _binary_ called **`pyccel`**.
 Any required Python packages will be installed automatically from PyPI.
 
 ### On a read-only system
@@ -251,7 +251,7 @@ Pyccel container images are available through both Docker Hub (<docker.io>) and
 The images:
 
 -   are based on `ubuntu:latest`
--   use distro packaged python3, GCC, GFortran, BLAS and OpenMPI
+-   use distro packaged Python3, GCC, GFortran, BLAS and OpenMPI
 -   support all Pyccel releases except the legacy "0.1"
 
 Image tags match Pyccel releases.
diff --git a/docs/openmp.md b/docs/openmp.md
index 3a55e21633..2e0d88b543 100644
--- a/docs/openmp.md
+++ b/docs/openmp.md
@@ -4,7 +4,7 @@
 
 OpenMP Runtime Library Routines for Pyccel work by importing the OpenMP routine needed from the `pyccel.stdlib`:
 
-Please note that files using the OpenMP Runtime library routines will only work when compiled with Pyccel (i.e. they won't work in pure python mode).
+Please note that files using the OpenMP Runtime library routines will only work when compiled with Pyccel (i.e. they won't work in pure Python mode).
 
 ```python
 from pyccel.stdlib.internal.openmp import omp_set_num_threads
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 603b2027e6..34229e9abc 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -44,10 +44,10 @@ It is the programmer's responsibility to explicitly declare the type of every va
 
 ### Further Reading
 
--   https://docs.python.org/3/tutorial/classes.html#a-word-about-names-and-objects
--   https://docs.python.org/3/reference/datamodel.html#objects-values-and-types
--   https://en.wikipedia.org/wiki/Type_system
--   https://android.jlelse.eu/magic-lies-here-statically-typed-vs-dynamically-typed-languages-d151c7f95e2b
+-   <https://docs.python.org/3/tutorial/classes.html#a-word-about-names-and-objects>
+-   <https://docs.python.org/3/reference/datamodel.html#objects-values-and-types>
+-   <https://en.wikipedia.org/wiki/Type_system>
+-   <https://android.jlelse.eu/magic-lies-here-statically-typed-vs-dynamically-typed-languages-d151c7f95e2b>
 
 ## How Pyccel works
 
@@ -388,7 +388,7 @@ from mod import f
 
 f_fast = epyccel(f)
 ```
-In practice `epyccel` copies the contents of `f` into a temporary python file in the `__epyccel__` directory.
+In practice `epyccel` copies the contents of `f` into a temporary Python file in the `__epyccel__` directory.
 As a result it is important that all imports are written inside the function when using `epyccel`.
 Once the file has been copied, `epyccel` calls the `pyccel` command to generate a Python C extension module that contains a single pyccelised function.
 Then finally, it imports this function and returns it to the caller.


Test patch:
diff --git a/.github/actions/pytest_run/action.yml b/.github/actions/pytest_run/action.yml
index 0511736fdf..fd7cdbda81 100644
--- a/.github/actions/pytest_run/action.yml
+++ b/.github/actions/pytest_run/action.yml
@@ -13,30 +13,72 @@ inputs:
 runs:
   using: "composite"
   steps:
-    - name: Test with pytest
+    - name: Set up runner
       run: |
         which python
         if [ -n "${{ runner.debug }}" ]
         then
           # If running in debug mode
-          export "DEBUG=1"
+          echo "DEBUG=1" >> $GITHUB_ENV
           export FLAGS="-xsv --log-cli-level DEBUG"
         else
           export FLAGS=""
         fi
-        python -m pytest -n auto -rXx ${FLAGS} -m "not (parallel or xdist_incompatible) and c ${{ inputs.pytest_mark }}" --ignore=symbolic --ignore=ndarrays
-        if [ -n "${SITE_DIR}" ]; then
+        echo "FLAGS=${FLAGS}" >> $GITHUB_ENV
+      shell: ${{ inputs.shell_cmd }}
+      working-directory: ./tests
+    - name: Pytest with options (1)
+      run: python -m pytest -n auto -rXx ${FLAGS} -m "not (parallel or xdist_incompatible) and c ${{ inputs.pytest_mark }}" --ignore=symbolic --ignore=ndarrays 2>&1 | tee s1_outfile.out
+      shell: ${{ inputs.shell_cmd }}
+      working-directory: ./tests
+      id: pytest_1
+    - name: if SITE_DIR is set (2)
+      run: |
+         if [ -n "${SITE_DIR}" ]; then
             echo "Touching"
             # Test ndarray folder update (requires parallel tests to avoid clean)
             touch ${SITE_DIR}/pyccel/stdlib/cwrapper/cwrapper.h
-            python -m pytest -n auto -rXx ${FLAGS} -m c -k test_array_int32_1d_scalar epyccel/test_arrays.py
-        fi
-        python -m pytest -rXx ${FLAGS} -m "xdist_incompatible and not parallel and c ${{ inputs.pytest_mark }}" --ignore=symbolic --ignore=ndarrays
-        pyccel-clean
-        python -m pytest -n auto -rXx ${FLAGS} -m "not (parallel or xdist_incompatible) and not (c or python) ${{ inputs.pytest_mark }}" --ignore=symbolic --ignore=ndarrays
-        python -m pytest -rXx ${FLAGS} -m "xdist_incompatible and not parallel and not (c or python) ${{ inputs.pytest_mark }}" --ignore=symbolic --ignore=ndarrays
+            python -m pytest -n auto -rXx ${FLAGS} -m c -k test_array_int32_1d_scalar epyccel/test_arrays.py 2>&1 | tee s2_outfile.out
+         fi
+      shell: ${{ inputs.shell_cmd }}
+      working-directory: ./tests
+      id: pytest_2
+    - name: pytest with options (3)
+      run: |
+         python -m pytest -rXx ${FLAGS} -m "xdist_incompatible and not parallel and c ${{ inputs.pytest_mark }}" --ignore=symbolic --ignore=ndarrays 2>&1 | tee s3_outfile.out
+         pyccel-clean
+      shell: ${{ inputs.shell_cmd }}
+      working-directory: ./tests
+      id: pytest_3
+    - name: pytest with options (4)
+      run: python -m pytest -n auto -rXx ${FLAGS} -m "not (parallel or xdist_incompatible) and not (c or python) ${{ inputs.pytest_mark }}" --ignore=symbolic --ignore=ndarrays 2>&1 | tee s4_outfile.out
+      shell: ${{ inputs.shell_cmd }}
+      working-directory: ./tests
+      id: pytest_4
+    - name: pytest with options (5)
+      run: |
+        python -m pytest -rXx ${FLAGS} -m "xdist_incompatible and not parallel and not (c or python) ${{ inputs.pytest_mark }}" --ignore=symbolic --ignore=ndarrays 2>&1 | tee s5_outfile.out
         pyccel-clean
-        python -m pytest ndarrays/ -rXx ${FLAGS}
+      shell: ${{ inputs.shell_cmd }}
+      working-directory: ./tests
+      id: pytest_5
+    - name: pytest ndarrays (6)
+      run: |
+        python -m pytest ndarrays/ -rXx ${FLAGS} 2>&1 | tee s6_outfile.out
         pyccel-clean
       shell: ${{ inputs.shell_cmd }}
       working-directory: ./tests
+      id: pytest_6
+    - name: Final step
+      if: always()
+      id: status
+      run:
+        python ci_tools/json_pytest_output.py -t "Linux Test Summary" --tests "C tests:${{ steps.pytest_1.outcome }}:tests/s1_outfile.out" \
+                       "Verification of stdlib update:${{ steps.pytest_2.outcome }}:tests/s2_outfile.out" \
+                       "Non-parallelisable C tests:${{ steps.pytest_3.outcome }}:tests/s3_outfile.out" \
+                       "Fortran tests:${{ steps.pytest_4.outcome }}:tests/s4_outfile.out" \
+                       "Non-parallelisable Fortran tests:${{ steps.pytest_5.outcome }}:tests/s5_outfile.out" \
+                       "Ndarray tests for C stdlib:${{ steps.pytest_6.outcome }}:tests/s6_outfile.out" 
+                      
+      shell: ${{ inputs.shell_cmd }}
+
diff --git a/.github/workflows/Github_pytest.yml b/.github/workflows/Github_pytest.yml
deleted file mode 100644
index b8f225a32e..0000000000
--- a/.github/workflows/Github_pytest.yml
+++ /dev/null
@@ -1,217 +0,0 @@
-name: Pull Request
-
-on:
-  pull_request:
-    branches: [ devel ]
-    types: [ opened, reopened, ready_for_review, synchronize, converted_to_draft ]
-  issue_comment:
-    types: [ created ]
-
-jobs:
-  Bot:
-    if: ${{ ( github.event_name == 'pull_request' ) || (github.event_name == 'issue_comment' && github.event.issue.pull_request ) }}
-    runs-on: ubuntu-latest
-    outputs:
-      run_linux: ${{ steps.run_bot.outputs.run_linux }}
-      run_windows: ${{ steps.run_bot.outputs.run_windows }}
-      run_macosx: ${{ steps.run_bot.outputs.run_macosx }}
-      run_coverage: ${{ steps.run_bot.outputs.run_coverage }}
-      run_docs: ${{ steps.run_bot.outputs.run_docs }}
-      run_pylint: ${{ steps.run_bot.outputs.run_pylint }}
-      run_lint: ${{ steps.run_bot.outputs.run_lint }}
-      run_spelling: ${{ steps.run_bot.outputs.run_spelling }}
-      run_pickle: ${{ steps.run_bot.outputs.run_pickle }}
-      run_editable_pickle: ${{ steps.run_bot.outputs.run_editable_pickle }}
-      run_pickle_wheel: ${{ steps.run_bot.outputs.run_pickle_wheel }}
-      run_linux_anaconda: ${{ steps.run_bot.outputs.run_linux_anaconda }}
-      run_windows_anaconda: ${{ steps.run_bot.outputs.run_windows_anaconda }}
-      BASE: ${{ steps.run_bot.outputs.BASE }}
-      REF: ${{ steps.run_bot.outputs.REF }}
-      SHA: ${{ steps.run_bot.outputs.SHA }}
-      status_url: ${{ steps.run_bot.outputs.status_url }}
-      cleanup_trigger: ${{ steps.run_bot.outputs.cleanup_trigger }}
-      python_version: ${{ steps.run_bot.outputs.python_version }}
-    steps:
-      - uses: actions/checkout@v3
-      - name: Set up Python ${{ inputs.python_version }}
-        uses: actions/setup-python@v4
-        with:
-          python-version: 3.9
-      - id: run_bot
-        run: |
-          python ci_tools/bot_interaction.py $GITHUB_EVENT_PATH $GITHUB_OUTPUT ${{ github.run_id }}
-        env:
-          GH_TOKEN: ${{ github.token }}
-      - name: Post status
-        if: ${{ steps.run_bot.outputs.cleanup_trigger != '' && github.event_name != 'pull_request' }}
-        run: |
-          gh api --method POST -H "Accept: application/vnd.github+json" \
-            ${{ steps.run_bot.outputs.status_url }} \
-            -f state='pending' -f target_url="${{ github.event.repository.html_url }}/actions/runs/${{ github.run_id }}" \
-            -f description="${{ github.event.comment.body }}" -f context="Tests on Draft"
-        env:
-          GH_TOKEN: ${{ github.token }}
-
-  linux:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_linux == 'True' }}
-    uses:
-      ./.github/workflows/linux.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      ref: ${{ needs.Bot.outputs.REF }}
-    secrets:
-      CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}
-
-  windows:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_windows == 'True' }}
-    uses:
-      ./.github/workflows/windows.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      ref: ${{ needs.Bot.outputs.REF }}
-
-  macosx:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_macosx == 'True' }}
-    uses:
-      ./.github/workflows/macosx.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      ref: ${{ needs.Bot.outputs.REF }}
-
-  coverage:
-    needs: [Bot, linux]
-    if: ${{ needs.Bot.outputs.run_coverage == 'True' }}
-    uses:
-      ./.github/workflows/coverage.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      base: ${{ needs.Bot.outputs.BASE }}
-      ref: ${{ needs.Bot.outputs.REF }}
-      sha: ${{ needs.Bot.outputs.SHA }}
-
-  docs:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_docs == 'True' }}
-    uses:
-      ./.github/workflows/doc_coverage.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      base: ${{ needs.Bot.outputs.BASE }}
-      ref: ${{ needs.Bot.outputs.REF }}
-
-  pylint:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_pylint == 'True' }}
-    uses:
-      ./.github/workflows/lint.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      ref: ${{ needs.Bot.outputs.REF }}
-
-  lint:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_lint == 'True' }}
-    uses:
-      ./.github/workflows/pyccel_lint.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      base: ${{ needs.Bot.outputs.BASE }}
-      ref: ${{ needs.Bot.outputs.REF }}
-
-  spelling:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_spelling == 'True' }}
-    uses:
-      ./.github/workflows/spelling.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      ref: ${{ needs.Bot.outputs.REF }}
-
-  pickle:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_pickle == 'True' }}
-    uses:
-      ./.github/workflows/pickle.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      ref: ${{ needs.Bot.outputs.REF }}
-
-  editable_pickle:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_editable_pickle == 'True' }}
-    uses:
-      ./.github/workflows/pickle.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      editable_string: '-e'
-      ref: ${{ needs.Bot.outputs.REF }}
-
-  pickle_wheel:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_pickle_wheel == 'True' }}
-    uses:
-      ./.github/workflows/pickle_wheel.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      ref: ${{ needs.Bot.outputs.REF }}
-
-
-  linux_anaconda:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_linux_anaconda == 'True' }}
-    uses:
-      ./.github/workflows/anaconda_linux.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      ref: ${{ needs.Bot.outputs.REF }}
-
-  windows_anaconda:
-    needs: Bot
-    if: ${{ needs.Bot.outputs.run_windows_anaconda == 'True' }}
-    uses:
-      ./.github/workflows/anaconda_windows.yml
-    with:
-      python_version: ${{ needs.Bot.outputs.python_version }}
-      ref: ${{ needs.Bot.outputs.REF }}
-
-  CleanUpBot:
-    needs:
-      - Bot
-      - linux
-      - windows
-      - macosx
-      - coverage
-      - docs
-      - pylint
-      - lint
-      - spelling
-      - pickle
-      - editable_pickle
-      - pickle_wheel
-      - linux_anaconda
-      - windows_anaconda
-    if: ${{ always() && needs.Bot.outputs.cleanup_trigger != '' }}
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v3
-      - name: Set up Python ${{ inputs.python_version }}
-        uses: actions/setup-python@v4
-        with:
-          python-version: 3.9
-      - id: run_bot
-        run: |
-          python ci_tools/bot_interaction.py $GITHUB_EVENT_PATH $GITHUB_OUTPUT ${{ github.run_id }} ${{ needs.Bot.outputs.cleanup_trigger }}
-        env:
-          GH_TOKEN: ${{ github.token }}
-      - name: Post status
-        if: ${{ github.event_name != 'pull_request' }}
-        run: |
-          gh api --method POST -H "Accept: application/vnd.github+json" \
-            ${{ needs.Bot.outputs.status_url }} \
-            -f state="${{ steps.run_bot.outputs.global_state }}" -f target_url="${{ github.event.repository.html_url }}/actions/runs/${{ github.run_id }}" \
-            -f description="${{ github.event.comment.body }}" -f context="Tests on Draft"
-        env:
-          GH_TOKEN: ${{ github.token }}
diff --git a/ci_tools/bot_messages/show_tests.txt b/ci_tools/bot_messages/show_tests.txt
index 79fe6e3478..17b72b2779 100644
--- a/ci_tools/bot_messages/show_tests.txt
+++ b/ci_tools/bot_messages/show_tests.txt
@@ -5,7 +5,7 @@ The following is a list of keywords which can be used to run tests. Tests in bol
 - **coverage** : Runs the unit tests on a Linux system and checks the coverage of the tests.
 - **docs** : Checks if the documentation follows the numpydoc format.
 - **pylint** : Runs pylint on files which are too big to be handled by codacy.
-- **lint** : Runs a linter to check that Pyccel's best practices are followed.
+- **pyccel_lint** : Runs a linter to check that Pyccel's best practices are followed.
 - **spelling** : Checks if everything in the documentation is spelled (and capitalised) correctly.
 - pr_tests : Runs all the tests marked in bold.
 - pickle : Checks that .pyccel files have been correctly generated and installed by the installation process.
diff --git a/ci_tools/json_pytest_output.py b/ci_tools/json_pytest_output.py
new file mode 100644
index 0000000000..a55f2691af
--- /dev/null
+++ b/ci_tools/json_pytest_output.py
@@ -0,0 +1,119 @@
+"""
+Command-line script for generating a test summary and saving it as a JSON file.
+
+This script parses command-line arguments to generate a test summary based on the provided inputs.
+It processes test outcomes, then generates a Markdown summary using the 'mini_md_summary' function.
+The resulting summary is printed to the console and saved as a JSON file.
+
+Usage:
+    -t, --title (str): The title of the test summary.
+    -test, --tests (list): Test specifications in the format 'Test title:outcome:output_file'.
+
+Example:
+    python script.py -t "Test Summary" --tests "Test 1:success:output.txt" "Test 2:failure:error.txt"
+"""
+import argparse
+import json
+import os
+import re
+import sys
+
+def     mini_md_summary(title, outcome, c, f, py):
+    """
+    Generate Markdown.
+
+    Generate a Markdown summary based on the provided information.
+
+    Parameters
+    ----------
+    title : str
+        The title of the summary.
+    outcome : str
+        The result of a completed step, Possible values are success, failure, cancelled, or skipped.
+    c : list
+        A list of C test summaries, (Failed tests).
+    f : list
+        A list of Fortran test summaries, (Failed tests).
+    py : list
+        A list of Python test summaries, (Failed tests).
+
+    Returns
+    -------
+    str
+        The Markdown summary string.
+    """
+    md = f"## {title} - {outcome} "
+    if outcome == "failure":
+        if len(c) != 0:
+            md = md + '\n' + "### C Test summary: "
+            md = md + '\n'
+            for i in c:
+                md = md + i + "\n"
+        if len(f) != 0:
+            md = md + '\n' + "### Fortran Test summary: "
+            md = md + '\n'
+            for i in f:
+                md = md + i + "\n"
+        if len(py) != 0:
+            md = md + '\n' + "### Python Test summary: "
+            md = md + '\n'
+            for i in py:
+                md = md + i + "\n"
+    md = md + "\n"
+    return(md)
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser()
+    parser.add_argument('-t', '--title', nargs='?', required=True, help='Tests summary title')
+    parser.add_argument('-test', '--tests', nargs='+', help="'Test title':outcome{success, failure, cancelled, or skipped}:output_file")
+    p_args = parser.parse_args()
+
+    outfile = ""
+    args = sys.argv[1:]
+    output_file = 'test_json_result.json'
+    summary = ""
+
+    for i in p_args.tests:
+        values = i.split(':')
+        mini_title = values[0] if len(values) >= 1 else None
+        outcome = values[1] if len(values) >= 2 else None
+        out_file = values[2] if len(values) >= 3 else None
+
+        if out_file is not None and os.path.exists(out_file):
+            with open(out_file , 'r', encoding='utf-8') as f:
+                outfile = f.read()
+
+            c_tests = []
+            f_tests = []
+            py_tests = []
+            failed_pattern = r".*FAILED.*"
+            c_pattern = r".*\[c\].*"
+            f_pattern = r".*\[fortran\]\ \_.*"
+            py_pattern = r".*\[python\]\ \_.*"
+
+            failed_matches = re.findall(failed_pattern, outfile, re.MULTILINE)
+            failed_matches = [re.sub(r'.*FAILED ', "- ``", string) for string in failed_matches]
+
+            r = re.compile(c_pattern)
+            c_failed = list(filter(r.match, failed_matches))
+            c_failed = [re.sub(r'\[c\]', "`` :heavy_multiplication_x:", string) for string in c_failed]
+
+            failed_matches = re.findall(f_pattern, outfile, re.MULTILINE)
+            failed_matches = ["- ``" + string.strip('_') for string in failed_matches]
+            f_failed = [re.sub(r'\[fortran\]', "`` :heavy_multiplication_x:", string) for string in failed_matches]
+
+            failed_matches = re.findall(py_pattern, outfile, re.MULTILINE)
+            failed_matches = ["- ``" + string.strip('_') for string in failed_matches]
+            py_failed = [re.sub(r'\[python\]', "`` :heavy_multiplication_x:", string) for string in failed_matches]
+
+
+        summary = summary + mini_md_summary(mini_title, outcome, c_failed, f_failed, py_failed)
+
+    print(summary)
+    json_ouput = {
+        "title":p_args.title,
+        "summary":summary
+    }
+
+    with open(output_file, 'w', encoding='utf-8') as f:
+        json.dump(json_ouput,f)