diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e1a14d22 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto + +# Explicitly declare text files you want to always be normalized and converted +# to native line endings on checkout. +*.py text eol=lf +*.rst text eol=lf +*.sh text eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 43322512..72418153 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,19 +1,18 @@ name: Test action -on: [pull_request] +on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - # - uses: actions/checkout@v2 + - uses: actions/checkout@v2 - uses: shenxianpeng/cpp-linter-action@master id: linter - with: - style: file - extensions: 'cpp' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + style: file - name: Fail fast?! if: steps.linter.outputs.checks-failed > 0 diff --git a/.gitignore b/.gitignore index 2edf4555..cb8d0354 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -clang-format-report.txt -clang-tidy-report.txt \ No newline at end of file +.cpp_linter_action_changed_files.json +clang_format_report.txt +clang_tidy_report.txt diff --git a/Dockerfile b/Dockerfile index 530e6462..48cab231 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,8 @@ -FROM xianpengshen/clang-tools:11 +FROM xianpengshen/clang-tools:all + +# WORKDIR option is set by the github action to the environment variable GITHUB_WORKSPACE. +# See https://docs.github.com/en/actions/creating-actions/dockerfile-support-for-github-actions#workdir + LABEL com.github.actions.name="cpp-linter check" LABEL com.github.actions.description="Lint your code with clang-tidy in parallel to your builds" @@ -13,4 +17,8 @@ RUN apt-get -y install curl jq COPY runchecks.sh /entrypoint.sh RUN chmod +x /entrypoint.sh + +# github action args use the CMD option +# See https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runsargs +# also https://docs.docker.com/engine/reference/builder/#cmd ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/README.md b/README.md index 5121259b..e297642a 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ Github Actions for linting the C/C++ code. Integrated clang-tidy, clang-format c Just create a `yml` file under your GitHub repository. For example `.github/workflows/cpp-linter.yml` -!!! Requires `secrets.GITHUB_TOKEN` set to an environment variable name "GITHUB_TOKEN". +!!! Requires `secrets.GITHUB_TOKEN` set to an environment variable named `GITHUB_TOKEN`. ```yml name: cpp-linter -on: [pull_request] +# Triggers the workflow on push or pull request events +on: [push, pull_request] jobs: cpp-linter: name: cpp-linter @@ -30,6 +31,13 @@ jobs: |------------|---------------|-------------| | style | 'llvm' | The style rules to use. Set this to 'file' to have clang-format use the closest relative .clang-format file. | | extensions | 'c,h,C,H,cpp,hpp,cc,hh,c++,h++,cxx,hxx' | The file extensions to run the action against. This is a comma-separated string. | +| tidy-checks | 'boost-*,bugprone-*,performance-*,readability-*,portability-*,modernize-*,clang-analyzer-*,cppcoreguidelines-*' | A string of regex-like patterns specifying what checks clang-tidy will use.| +| repo-root | '.' | The relative path to the repository root directory. This path is relative to path designated by the runner's GITHUB_WORKSPACE environment variable. | +| version | '10' | The desired version of the clang tools to use. Accepted options are strings which can be 6.0, 7, 8, 9, 10, 11, 12. | + +### Outputs + +This action creates 1 output variable named `checks-failed`. Even if the linting checks fail for source files this action will still pass, but users' CI workflows can use this action's output to exit the workflow early if that is desired. ### Outputs diff --git a/action.yml b/action.yml index c4371152..066a7ad0 100644 --- a/action.yml +++ b/action.yml @@ -5,14 +5,33 @@ branding: icon: 'check-circle' color: 'green' inputs: - style: # the specific style rules - description: "The style rules to use (defaults to 'llvm'). Set this to 'file' to have clang-format use the closest relative .clang-format file." + style: + description: > + The style rules to use (defaults to 'llvm'). + Set this to 'file' to have clang-format use the closest relative .clang-format file. required: false default: 'llvm' extensions: - description: "The file extensions to run the action against. This comma-separated string defaults to 'c,h,C,H,cpp,hpp,cc,hh,c++,h++,cxx,hxx'." + description: > + The file extensions to run the action against. + This comma-separated string defaults to 'c,h,C,H,cpp,hpp,cc,hh,c++,h++,cxx,hxx'. required: false default: "c,h,C,H,cpp,hpp,cc,hh,c++,h++,cxx,hxx" + tidy-checks: + description: > + A string of regex-like patterns specifying what checks clang-tidy will use. + This defaults to 'boost-*,bugprone-*,performance-*,readability-*,portability-*,modernize-*,clang-analyzer-*,cppcoreguidelines-*'. See also clang-tidy docs for more info. + required: false + default: 'boost-*,bugprone-*,performance-*,readability-*,portability-*,modernize-*,clang-analyzer-*,cppcoreguidelines-*' + repo-root: + description: > + The relative path to the repository root directory. The default value '.' is relative to the runner's GITHUB_WORKSPACE environment variable. + required: false + default: '.' + version: + description: "The desired version of the clang tools to use. Accepted options are strings which can be 6.0, 7, 8, 9, 10, 11, 12. Defaults to 10." + required: false + default: '10' outputs: checks-failed: description: An integer that can be used as a boolean value to indicate if all checks failed. @@ -22,3 +41,6 @@ runs: args: - ${{ inputs.style }} - ${{ inputs.extensions }} + - ${{ inputs.tidy-checks }} + - ${{ inputs.repo-root }} + - ${{ inputs.version }} diff --git a/demo/compile_commands.json b/demo/compile_commands.json index 2643be40..d782c455 100644 --- a/demo/compile_commands.json +++ b/demo/compile_commands.json @@ -1,7 +1,12 @@ [ -{ - "directory": ".", - "command": "/usr/bin/g++ -Wall -Werror demo.cpp", - "file": "/demo.cpp" -} + { + "directory": ".", + "command": "/usr/bin/g++ -Wall -Werror demo.cpp", + "file": "/demo.cpp" + }, + { + "directory": ".", + "command": "/usr/bin/g++ -Wall -Werror demo.cpp", + "file": "/demo.hpp" + } ] diff --git a/demo/demo.cpp b/demo/demo.cpp index fcf995c0..46cfb299 100644 --- a/demo/demo.cpp +++ b/demo/demo.cpp @@ -1,5 +1,5 @@ /** This is a very ugly test code (doomed to fail linting) */ - +#include "demo.hpp" #include @@ -9,6 +9,8 @@ int main(){ for (;;) break; + printf("Hello world!\n"); + return 0;} diff --git a/demo/demo.hpp b/demo/demo.hpp new file mode 100644 index 00000000..0bf1104a --- /dev/null +++ b/demo/demo.hpp @@ -0,0 +1,39 @@ +#pragma once + + + +class Dummy { + char* useless; + int numb; + Dummy() :numb(0), useless("\0"){} + + public: + void *not_usefull(char *str){useless = str;} +}; + + + + + + + + + + + + + + + + + + + + + + +struct LongDiff +{ + long diff; + +}; diff --git a/runchecks.sh b/runchecks.sh index c832bde0..44eaf625 100644 --- a/runchecks.sh +++ b/runchecks.sh @@ -1,10 +1,28 @@ #!/bin/bash +# global varibales EXIT_CODE="0" -PAYLOAD_FORMAT="" PAYLOAD_TIDY="" +FENCES=$'\n```\n' +OUTPUT="" +URLS="" +PATHNAMES="" +declare -a JSON_INDEX -function set_exit_code () { +# alias CLI args +args=("$@") +FMT_STYLE=${args[0]} +IFS=',' read -r -a FILE_EXT_LIST <<< "${args[1]}" +TIDY_CHECKS="${args[2]}" +cd "${args[3]}" || exit "1" +CLANG_VERSION="${args[4]}" + + +################################################### +# Set the exit code (for expected exit calls). +# Optional parameter overides action-specific logic +################################################### +set_exit_code () { if [[ $# -gt 0 ]] then EXIT_CODE="$1" @@ -17,102 +35,234 @@ function set_exit_code () { echo "::set-output name=checks-failed::$EXIT_CODE" } -# check for access token (ENV VAR needed for git API calls) -if [[ -z "$GITHUB_TOKEN" ]] -then - echo "The GITHUB_TOKEN is required." - set_exit_code "1" - exit "$EXIT_CODE" -fi +################################################### +# Fetch JSON of event's changed files +################################################### +get_list_of_changed_files() { + echo "GH_EVENT_PATH = $GITHUB_EVENT_PATH" + echo "processing $GITHUB_EVENT_NAME event" + # cat "$GITHUB_EVENT_PATH" | jq '.' -# parse CLI args -args=("$@") -FMT_STYLE=${args[0]} -IFS=',' read -r -a FILE_EXT_LIST <<< "${args[1]}" + # Use git REST API payload + if [[ "$GITHUB_EVENT_NAME" == "push" ]] + then + FILES_LINK="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" + elif [[ "$GITHUB_EVENT_NAME" == "pull_request" ]] + then + # FILES_LINK="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls//files" + # Get PR ID number from the event's JSON located in the runner's GITHUB_EVENT_PATH + FILES_LINK="$(jq -r '.pull_request._links.self.href' "$GITHUB_EVENT_PATH")/files" + fi -# use git API payload -FILES_LINK=`jq -r '.pull_request._links.self.href' "$GITHUB_EVENT_PATH"`/files -echo "Files = $FILES_LINK" - -# setup download URLS -curl $FILES_LINK > files.json -FILES_URLS_STRING=`jq -r '.[].raw_url' files.json` -readarray -t URLS <<<"$FILES_URLS_STRING" - -# exclude undesired files -for index in "${!URLS[@]}" -do - is_supported=0 - for i in "${FILE_EXT_LIST[@]}" - do - if [[ ${URLS[index]} == *".$i" ]] - then - is_supported=1 - fi - done - if [ $is_supported == 0 ] - then - unset -v "URLS[index]" - fi -done - -# exit early if nothing to do -if [ ${#URLS[@]} == 0 ] -then - set_exit_code "0" - echo "No source files need checking!" - exit $EXIT_CODE -else - echo "File names: ${URLS[*]}" -fi - -mkdir files -cd files -for i in "${URLS[@]}" -do - echo "Downloading $i" - curl -LOk --remote-name $i -done - -echo "Files downloaded!" -echo "Performing checkup:" -clang-tidy --version - -for i in "${URLS[@]}" -do - filename=`basename $i` - clang-tidy $filename -checks=boost-*,bugprone-*,performance-*,readability-*,portability-*,modernize-*,clang-analyzer-cplusplus-*,clang-analyzer-*,cppcoreguidelines-* >> clang-tidy-report.txt - clang-format -style="$FMT_STYLE" --dry-run -Werror "$filename" || echo "File: $filename not formatted!" >> clang-format-report.txt -done - -PAYLOAD_TIDY=`cat clang-tidy-report.txt` -PAYLOAD_FORMAT=`cat clang-format-report.txt` -COMMENTS_URL=$(cat $GITHUB_EVENT_PATH | jq -r .pull_request.comments_url) - -echo $COMMENTS_URL -echo "Clang-tidy errors:" -echo $PAYLOAD_TIDY -echo "Clang-format errors:" -echo $PAYLOAD_FORMAT - -if [ "$PAYLOAD_TIDY" != "" ]; then - OUTPUT=$'**CLANG-TIDY WARNINGS**:\n' - OUTPUT+=$'\n```\n' - OUTPUT+="$PAYLOAD_TIDY" - OUTPUT+=$'\n```\n' -fi - -if [ "$PAYLOAD_FORMAT" != "" ]; then - OUTPUT=$'**CLANG-FORMAT WARNINGS**:\n' - OUTPUT+=$'\n```\n' - OUTPUT+="$PAYLOAD_FORMAT" - OUTPUT+=$'\n```\n' -fi + # Download files list (another JSON containing files' names, URLS, statuses, & diffs/patches) + echo "Fetching files list from $FILES_LINK" + curl "$FILES_LINK" > .cpp_linter_action_changed_files.json +} -set_exit_code +################################################### +# extract info from downloaded JSON file +################################################### +extract_changed_files_info() { + # pull_request events have a slightly different JSON format than push events + JSON_FILES=".[" + if [[ "$GITHUB_EVENT_NAME" == "push" ]] + then + JSON_FILES=".files[" + fi + FILES_URLS_STRING=$(jq -r "$JSON_FILES].raw_url" .cpp_linter_action_changed_files.json) + FILES_NAMES_STRING=$(jq -r "$JSON_FILES].filename" .cpp_linter_action_changed_files.json) + + # convert json info to arrays + readarray -t URLS <<<"$FILES_URLS_STRING" + readarray -t PATHNAMES <<<"$FILES_NAMES_STRING" + + # Initialize the `JSON_INDEX` array. This helps us keep track of the + # source files' index in the JSON after calling `filter_out_source_files()` function. + for index in "${!URLS[@]}" + do + # this will only be used when parsing diffs from the JSON + JSON_INDEX[$index]=$index + done +} -echo "OUTPUT is: \n $OUTPUT" +################################################### +# exclude undesired files (specified by the user) +################################################### +filter_out_non_source_files() { + for index in "${!URLS[@]}" + do + is_supported=0 + for i in "${FILE_EXT_LIST[@]}" + do + if [[ ${URLS[index]} == *".$i" ]] + then + is_supported=1 + break + fi + done -PAYLOAD=$(echo '{}' | jq --arg body "$OUTPUT" '.body = $body') + if [ $is_supported == 0 ] + then + unset -v "URLS[index]" + unset -v "PATHNAMES[index]" + unset -v "JSON_INDEX[index]" + fi + done -curl -s -S -H "Authorization: token $GITHUB_TOKEN" --header "Content-Type: application/vnd.github.VERSION.text+json" --data "$PAYLOAD" "$COMMENTS_URL" + # exit early if nothing to do + if [ ${#URLS[@]} == 0 ] + then + set_exit_code "0" + echo "No source files need checking!" + exit $EXIT_CODE + else + echo "File names: ${PATHNAMES[*]}" + fi +} + +################################################### +# Download the files if not present. +# This function assumes that the working directory is the root of the invoking repo. +# Note that all github actions are run in path specified by the environment variable GITHUB_WORKSPACE. +################################################### +verify_files_are_present() { + # URLS, PATHNAMES, & PATCHES are parallel arrays + for index in "${!PATHNAMES[@]}" + do + if [[ ! -f "${PATHNAMES[index]}" ]] + then + echo "Downloading ${URLS[index]}" + curl --location --insecure --remote-name "${URLS[index]}" + fi + done +} + +################################################### +# get the patch info from the JSON. +# required parameter is the index in the JSON_INDEX array +################################################### +get_patch_info() { + # patches are multiline strings. Thus, they need special attention because of the '\n' used within. + # + # a git diff (aka "patch" in the REST API) can have multiple "hunks" for a single file. + # hunks start with `@@ -, +, @@` + # A positive sign indicates the incoming changes, while a negative sign indicates existing code that was changed + # Any changed lines will also have a prefixed `-` or `+`. + + file_status=$(jq -r "$JSON_FILES${JSON_INDEX[$1]}].status" .cpp_linter_action_changed_files.json) + + # we only need the first line stating the line numbers changed (ie "@@ -1,5 +1,5 @@"") + patched_lines=$(jq -r -c "$JSON_FILES${JSON_INDEX[$1]}].patch" .cpp_linter_action_changed_files.json) + patches=$(echo "$patched_lines" | grep -o "@@ \\-[1-9]*,[1-9]* +[1-9]*,[1-9]* @@" | grep -o " +[1-9]*,[1-9]*" | tr -d "\\n" | sed 's; +;;; s;+;;g') + + # if there is no patch field, we need to handle 'renamed' as an edgde case + if [[ "$patches" == "" ]] + then + echo "${PATHNAMES[$1]} was $file_status" + # don't bother checking renamed files with no changes to file's content + patches="0,0" + fi + echo "$patches" +} + +################################################### +# execute clang-tidy/format & assemble a unified OUTPUT +################################################### +capture_clang_tools_output() { + clang-tidy --version + + for index in "${!URLS[@]}" + do + filename=$(basename ${URLS[index]}) + if [[ -f "${PATHNAMES[index]}" ]] + then + filename="${PATHNAMES[index]}" + fi + + true > clang_format_report.txt + true > clang_tidy_report.txt + + echo "Performing checkup on $filename" + # echo "incoming changed lines: $(get_patch_info $index)" + + if [ "$TIDY_CHECKS" == "" ] + then + clang-tidy-"$CLANG_VERSION" "$filename" >> clang_tidy_report.txt + else + clang-tidy-"$CLANG_VERSION" -checks="$TIDY_CHECKS" "$filename" >> clang_tidy_report.txt + fi + clang-format-"$CLANG_VERSION" -style="$FMT_STYLE" --dry-run "$filename" 2> clang_format_report.txt + + if [[ $(wc -l < clang_tidy_report.txt) -gt 0 ]] + then + PAYLOAD_TIDY+=$"### ${PATHNAMES[index]}" + PAYLOAD_TIDY+="$FENCES" + sed -i "s|$GITHUB_WORKSPACE/||g" clang_tidy_report.txt + # cat clang_tidy_report.txt + PAYLOAD_TIDY+=$(cat clang_tidy_report.txt) + PAYLOAD_TIDY+="$FENCES" + fi + + if [[ $(wc -l < clang_format_report.txt) -gt 0 ]] + then + if [ "$OUTPUT" == "" ] + then + OUTPUT=$'## Run `clang-format` on the following files\n' + fi + OUTPUT+="- [ ] ${PATHNAMES[index]}"$'\n' + fi + done + + if [ "$PAYLOAD_TIDY" != "" ]; then + OUTPUT+=$'\n---\n## Output from `clang-tidy`\n' + OUTPUT+="$PAYLOAD_TIDY" + fi + + echo "OUTPUT is:" + echo "$OUTPUT" +} + +################################################### +# POST action's results using REST API +################################################### +post_results() { + # check for access token (ENV VAR needed for git API calls) + if [[ -z "$GITHUB_TOKEN" ]] + then + set_exit_code "1" + echo "The GITHUB_TOKEN is required." + exit "$EXIT_CODE" + fi + + COMMENTS_URL=$(jq -r .pull_request.comments_url "$GITHUB_EVENT_PATH") + if [[ "$GITHUB_EVENT_NAME" == "push" ]] + then + COMMENTS_URL="$FILES_LINK/comments" + fi + + echo "COMMENTS_URL = $COMMENTS_URL" + + PAYLOAD=$(echo '{}' | jq --arg body "$OUTPUT" '.body = $body') + + # creating PR comments is the same API as creating issue. Creating commit comments have more optional parameters (but same required API) + curl -s -S -H "Authorization: token $GITHUB_TOKEN" --header "Content-Type: application/vnd.github.VERSION.text+json" "$COMMENTS_URL" --data "$PAYLOAD" +} + +################################################### +# The main body of this script (all function calls) +################################################### +# for local testing (without docker): +# 1. Set the env var GITHUB_EVENT_NAME to "push" or "pull_request" +# 2. Download and save the event's payload (in JSON) to a file named ".cpp_linter_action_changed_files.json". +# See the FILES_LINK variable in the get_list_of_changed_files() function for the event's payload. +# 3. Comment out the following calls to `get_list_of_changed_files` & `post_results` functions +# 4. Run this script using `./run_checks.sh