├── .github └── workflows │ ├── goaction.yaml │ ├── oci.yaml │ └── release.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── go.mod ├── go.sum └── main.go /.github/workflows/goaction.yaml: -------------------------------------------------------------------------------- 1 | name: goaction 2 | 3 | on: 4 | #schedule: 5 | #- cron: '0 0 */15 * *' # uncomment this schedule to run at midnight every 15 day of every months 6 | push: 7 | paths: 8 | - '**' 9 | - '!README.md' 10 | - '!**/.gitignore' 11 | - '!**/Dockerfile' 12 | - '!**/LICENSE' 13 | 14 | jobs: 15 | lint-and-goaction: 16 | runs-on: ubuntu-latest 17 | permissions: write-all 18 | 19 | steps: 20 | - name: Checkout Repository 21 | uses: actions/checkout@v2 22 | 23 | - name: Set up Go 24 | uses: actions/setup-go@v2 25 | with: 26 | go-version: '1.20' 27 | 28 | - name: Lint Go Code 29 | run: go vet ./... 30 | 31 | - name: Check out repository 32 | uses: actions/checkout@v3 33 | 34 | - name: Set up Go 35 | uses: actions/setup-go@v3 36 | with: 37 | go-version: '1.20' 38 | 39 | - name: Set up environment variables 40 | run: | 41 | echo "TOKEN=${{ secrets.TOKEN }}" >> $GITHUB_ENV 42 | echo "REPO_FOLDER_1=en" >> $GITHUB_ENV 43 | echo "REPO_FOLDER_2=it" >> $GITHUB_ENV 44 | echo "REPO_URL=https://github.com/r3drun3/content-sync-tester" >> $GITHUB_ENV 45 | echo "OPEN_ISSUE=true" >> $GITHUB_ENV 46 | 47 | # uncomment the following to execute guthub-content-sync inside the CI pipeline 48 | # - name: Execute Go Script 49 | # run: go run main.go 50 | # env: 51 | # GITHUB_TOKEN: ${{ secrets.TOKEN }} 52 | # REPO_FOLDER_1: ${{ env.REPO_FOLDER_1 }} 53 | # REPO_FOLDER_2: ${{ env.REPO_FOLDER_2 }} 54 | # REPO_URL: ${{ env.REPO_URL }} 55 | # OPEN_ISSUE: ${{ env.OPEN_ISSUE }} 56 | -------------------------------------------------------------------------------- /.github/workflows/oci.yaml: -------------------------------------------------------------------------------- 1 | name: oci 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | build-and-publish-oci: 10 | runs-on: ubuntu-latest 11 | permissions: write-all 12 | 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: '1.20' 21 | 22 | - name: Convert Repository Name to Lowercase 23 | id: lowercase 24 | run: echo "::set-output name=name::$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" 25 | 26 | - name: Extract Version 27 | id: tagger 28 | uses: battila7/get-version-action@v2 29 | 30 | - name: Print Version 31 | run: | 32 | echo ${{steps.tagger.outputs.version}} 33 | echo ${{steps.tagger.outputs.version-without-v}} 34 | 35 | - name: Build the OCI Image 36 | run: docker build -t ghcr.io/${{ steps.lowercase.outputs.name }}:${{ steps.tagger.outputs.version-without-v }} . 37 | working-directory: . 38 | - name: Login to GitHub Packages 39 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin 40 | 41 | - name: Push the OCI Image 42 | run: docker push ghcr.io/${{ steps.lowercase.outputs.name }}:${{ steps.tagger.outputs.version-without-v }} 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | permissions: 9 | contents: write 10 | # packages: write 11 | # issues: write 12 | 13 | jobs: 14 | goreleaser: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | - run: git fetch --force --tags 21 | - uses: actions/setup-go@v4 22 | with: 23 | go-version: stable 24 | - uses: goreleaser/goreleaser-action@v4 25 | with: 26 | # either 'goreleaser' (default) or 'goreleaser-pro': 27 | distribution: goreleaser 28 | version: latest 29 | args: release --clean 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macos development 2 | .DS_Store 3 | 4 | # vscode files 5 | .vscode -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Start with a base Golang image 2 | FROM golang:1.23rc2-alpine 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy the Go script into the container 8 | COPY . . 9 | 10 | # Build the Go binary 11 | RUN go build -o github-content-sync . 12 | 13 | # Set the entry point as CMD 14 | CMD ["./github-content-sync"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 simone ragonesi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GITHUB CONTENT SYNC 🔎 📁 2 | [![goaction](https://github.com/R3DRUN3/github-content-sync/actions/workflows/goaction.yaml/badge.svg)](https://github.com/R3DRUN3/github-content-sync/actions/workflows/goaction.yaml) 3 | [![goreleaser](https://github.com/R3DRUN3/github-content-sync/actions/workflows/release.yaml/badge.svg)](https://github.com/R3DRUN3/github-content-sync/actions/workflows/release.yaml) 4 | [![oci](https://github.com/R3DRUN3/github-content-sync/actions/workflows/oci.yaml/badge.svg)](https://github.com/R3DRUN3/github-content-sync/actions/workflows/oci.yaml) 5 | [![Latest Release](https://img.shields.io/github/release/R3DRUN3/github-content-sync.svg)](https://github.com/R3DRUN3/github-content-sync/releases/latest) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/r3drun3/github-content-sync)](https://goreportcard.com/report/github.com/r3drun3/github-content-sync) 8 | 9 | The *Github Content Sync* tool is a command-line script written in *Go* that allows you to compare the contents of *two folders* within a GitHub repository. 10 | It helps identify files difference between the two folders. 11 | 12 |
13 | 14 | Basically, if `A` and `B` are the two folders, the tool will output: 15 | - files present in `A` but not in `B` 16 | - files present in `B` but not in `A` 17 | - files present in both `A` and `B` but with newer commits in `A` 18 | 19 | > [!NOTE] 20 | > You can also do cross-branches comparison by specifing the branches for both directories. 21 | 22 | ## Purpose 23 | 24 | This tool has been specifically developed to assist the *Special Interest Groups (SIGs)* responsible for *glossary* management within the [CNCF](https://github.com/cncf). 25 | The purpose of the tool is to facilitate the comparison of folder contents within a GitHub repository. 26 | **This was specifically meant for those repo that contain documentation in various languages** (divided into different folders) and you need a fast way to know the deltas: 27 | In this case, usually the reference folder and "*source of truth*" is the "*english*" one (for a real world example take a look at [this repo](https://github.com/cncf/glossary/tree/main/content), for a test playground we use [this one](https://github.com/R3DRUN3/content-sync-tester)). 28 | Generally, it can be useful in scenarios where you have two folders within a repository and you want to identify the differences between them, such as missing files or files with newer commits. 29 | ## Arguments 30 | 31 | The script requires the following environment variables to be set: 32 | - `REPO_URL`: The URL of the GitHub repository to analyze. [MANDATORY] 33 | - `REPO_FOLDER_1`: The name of the reference folder (source of truth, or folder `A`). [MANDATORY] 34 | - `REPO_FOLDER_2`: The name of the second folder to compare to the reference folder (folder `B`). [MANDATORY] 35 | - `TOKEN`: An access token with appropriate permissions to *read* and *open issues* on the target repo. [MANDATORY] 36 | - `FOLDER_1_BRANCH`: The branch for the first folder. If not specified, the default is main [OPTIONAL] 37 | - `FOLDER_2_BRANCH`: The branch for the second folder. If not specified, the default is main [OPTIONAL] 38 | - `OPEN_ISSUE`: If set to `true`, this specify that the script needs to open a "*synchronization issue*" on the target repo, specifying the folder differences. [OPTIONAL] 39 | The opened issues are structured like [this one](https://github.com/R3DRUN3/content-sync-tester/issues/29). 40 | - `MULTIPLE_ISSUES`: If `OPEN_ISSUE` is set to `true` and this var is also set to `true`, the script will create multiple issues, one for every file difference. [OPTIONAL] 41 | 42 | 43 | > [!WARNING] 44 | > Be careful when setting the `MULTIPLE_ISSUES` var to *true*: if you execute this script against two folders with many files, it will create many issues on your target repo. 45 | 46 | 47 | ## How it works 48 | 49 | The script performs the following steps: 50 | 1. Checks the presence of the required environment variables and their values. 51 | 1. Creates a GitHub client using the provided access token. 52 | 1. Retrieve the content of the two specified folders via the Github client object. 53 | 1. Compares the contents of the two specified folders within the repository. 54 | 1. Prints the files that are present in the first folder but not in the second folder. 55 | 1. Prints the files with newer commits in the first folder compared to the same files in the second folder. 56 | 1. Prints the files that are present in the second folder but not in the first folder. 57 | 2. If `OPEN_ISSUE` env var is present and set to `true`, opens a "synchronization issue" on the target repo. 58 | ## Examples 59 | 60 | You can run this utility in many ways: 61 | 62 | ### As an Executable 63 | Download the [release](https://github.com/R3DRUN3/github-content-sync/releases/) that you want and run it: 64 | 65 | ```shell 66 | 67 | export REPO_URL=https://github.com/R3DRUN3/content-sync-tester 68 | export REPO_FOLDER_1=en 69 | export REPO_FOLDER_2=it 70 | export TOKEN= 71 | 72 | ./github-content-sync 73 | ``` 74 | 75 | 76 | Output: 77 | ```console 78 | __ __ _____ _ __ _ __ ___ __ _ _ __ _____ ___ _ __ _____ ___ _ __ _ __ __ 79 | ,'_/ / //_ _/ /// / /// / / o.) ,'_/ ,' \ / |/ //_ _/ / _/ / |/ //_ _/ ,' _/ | |/,' / |/ / ,'_/ 80 | / /_n / / / / / ` / / U / / o \ / /_ / o | / || / / / / _/ / || / / / _\ `. | ,' / || / / /_ 81 | |__,'/_/ /_/ /_n_/ \_,' /___,' |__/ |_,' /_/|_/ /_/ /___/ /_/|_/ /_/ /___,' /_/ /_/|_/ |__/ 82 | 83 | [ ALL ENVIRONMENT VARIABLES ARE CONFIGURED ] 84 | [ TARGET REPO URL: https://github.com/R3DRUN3/content-sync-tester ] 85 | 86 | [ FILES PRESENT IN en BUT NOT IN it ] 87 | not_present_in_it.md 88 | not_present_in_it_2.md 89 | test.md 90 | 91 | 92 | [ FILES PRESENT IN BOTH en AND it WITH NEWER COMMITS IN en ] 93 | doc2.md 94 | last.md 95 | 96 | 97 | ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ 98 | /__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__//__/ 99 | ``` 100 | 101 | ### With Docker (Local Build) 102 | This repo also contain a Dockerfile so you can launch the script as a docker container. 103 | Clone the repo locally and buil the image: 104 | ```console 105 | git clone https://github.com/r3drun3/github-content-sync \ 106 | && cd github-content-sync \ 107 | && docker build -t github-content-sync:latest . 108 | ``` 109 | 110 | Run the docker container (change env vars accordingly): 111 | ```console 112 | docker run -it --rm -e REPO_URL=https://github.com/cncf/glossary -e REPO_FOLDER_1=content/en -e REPO_FOLDER_2=content/it -e TOKEN= github-content-sync:latest 113 | ``` 114 | 115 | 116 | ### With Docker (Github Packages) 117 | Alternatively, this repo already contains an action to publish the script's OCI image to [Github Packages](https://github.com/features/packages). 118 | Pull the version that you want: 119 | ```console 120 | docker pull ghcr.io/r3drun3/github-content-sync:1.5.0 121 | ``` 122 | 123 | Run the docker container (change env vars accordingly): 124 | ```console 125 | docker run -it --rm -e REPO_URL=https://github.com/cncf/glossary -e REPO_FOLDER_1=content/en -e REPO_FOLDER_2=content/it -e TOKEN= ghcr.io/r3drun3/github-content-sync:1.5.0 126 | ``` 127 | 128 | ### Run via Github Action 129 | The script in this repo can also executed inside a *Github action*, for an example take a look at the [goaction](https://github.com/R3DRUN3/github-content-sync/actions/workflows/goaction.yaml) Github Action associated to this repo. 130 | 131 | 132 | ## Development and Debug 133 | For development and debug I suggest the use of the [VS Code](https://code.visualstudio.com/) IDE. 134 | In order to debug the script locally, you can create the `.vscode/launch.json` file with the following structure: 135 | ```json 136 | { 137 | "version": "0.2.0", 138 | "configurations": [ 139 | { 140 | "name": "Launch", 141 | "type": "go", 142 | "request": "launch", 143 | "mode": "auto", 144 | "program": "${workspaceFolder}/main.go", 145 | "env": { 146 | "REPO_URL": "", 147 | "REPO_FOLDER_1": "", 148 | "REPO_FOLDER_2": "", 149 | "TOKEN": "", 150 | "OPEN_ISSUE": "false", 151 | "MULTIPLE_ISSUES": "false" 152 | } 153 | } 154 | ] 155 | } 156 | ``` 157 | 158 | 159 | 160 | ## Improvements and Next Steps 161 | 162 | - It can be useful to maybe add the possibility of comparing multiple folders at the same time, not just 2. 163 | 164 | 165 | ## License 166 | 167 | This script is released under the [MIT License](https://opensource.org/license/mit/). 168 | Feel free to modify and distribute it as per your needs. 169 | 170 | 171 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/r3drun3/github-content-sync 2 | 3 | go 1.20 4 | 5 | require golang.org/x/oauth2 v0.9.0 6 | 7 | require ( 8 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect 9 | github.com/cloudflare/circl v1.3.7 // indirect 10 | github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be 11 | golang.org/x/crypto v0.17.0 // indirect 12 | golang.org/x/sys v0.15.0 // indirect 13 | ) 14 | 15 | require ( 16 | github.com/golang/protobuf v1.5.2 // indirect 17 | github.com/google/go-github/v53 v53.2.0 18 | github.com/google/go-querystring v1.1.0 // indirect 19 | golang.org/x/net v0.11.0 // indirect 20 | google.golang.org/appengine v1.6.7 // indirect 21 | google.golang.org/protobuf v1.28.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= 2 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= 3 | github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 4 | github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= 5 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 6 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 7 | github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= 8 | github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= 9 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 10 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 11 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 12 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 13 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 14 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 15 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 16 | github.com/google/go-github/v53 v53.2.0 h1:wvz3FyF53v4BK+AsnvCmeNhf8AkTaeh2SoYu/XUvTtI= 17 | github.com/google/go-github/v53 v53.2.0/go.mod h1:XhFRObz+m/l+UCm9b7KSIC3lT3NWSXGt7mOsAWEloao= 18 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 19 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 20 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 21 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 22 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 23 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 24 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 25 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 26 | golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU= 27 | golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= 28 | golang.org/x/oauth2 v0.9.0 h1:BPpt2kU7oMRq3kCHAA1tbSEshXRw1LpG2ztgDwrzuAs= 29 | golang.org/x/oauth2 v0.9.0/go.mod h1:qYgFZaFiu6Wg24azG8bdV52QJXJGbZzIIsRCdVKzbLw= 30 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 31 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 32 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 | golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 35 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 36 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 37 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 38 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 39 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 40 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 41 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 42 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 43 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 44 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 45 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 46 | google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= 47 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 48 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "sort" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/common-nighthawk/go-figure" 14 | "github.com/google/go-github/v53/github" 15 | "golang.org/x/oauth2" 16 | ) 17 | 18 | func main() { 19 | header := figure.NewFigure("GITHUB CONTENT SYNC", "eftitalic", true) 20 | header.Print() 21 | envVars, err := getEnvVariables() 22 | if err != nil { 23 | fmt.Println("Error:", err) 24 | return 25 | } 26 | 27 | // Read environment variables 28 | repoURL := envVars[0] 29 | folder1 := envVars[1] 30 | folder2 := envVars[2] 31 | token := envVars[3] 32 | folder1Branch := os.Getenv("FOLDER_1_BRANCH") 33 | if folder1Branch == "" { 34 | folder1Branch = "main" 35 | } 36 | folder2Branch := os.Getenv("FOLDER_2_BRANCH") 37 | if folder2Branch == "" { 38 | folder2Branch = "main" 39 | } 40 | 41 | // Create a GitHub client with the provided token 42 | client := createGitHubClient(token) 43 | fmt.Println("[ TARGET REPO URL: ", repoURL, "]") 44 | fmt.Println("\n[ FILES PRESENT IN", folder1, "ON BRANCH", folder1Branch, "BUT NOT IN", folder2, "ON BRANCH", folder2Branch, "]") 45 | // Compare folders and get files present in folder1 but not in folder2 46 | diffFiles, newerFiles, diffFilesFolder2, err := compareFolders(client, repoURL, folder1, folder1Branch, folder2, folder2Branch) 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | printFilesSorted(diffFiles) 51 | 52 | fmt.Println("\n\n[ FILES PRESENT IN BOTH", folder1, "AND", folder2, "WITH NEWER COMMITS IN", folder1, "]") 53 | // Print files present in both folder1 and folder2 with newer commits in folder1 54 | printFilesSorted(newerFiles) 55 | 56 | fmt.Println("\n\n[ FILES PRESENT IN", folder2, "ON BRANCH", folder2Branch, "BUT NOT IN", folder1, "ON BRANCH", folder1Branch, "]") 57 | // Print files present in folder2 but not in folder1 58 | printFilesSorted(diffFilesFolder2) 59 | 60 | // Open an issue if OPEN_ISSUE env var is set to true 61 | if os.Getenv("OPEN_ISSUE") == "true" { 62 | err := openSyncIssue(client, repoURL, folder1, folder2, diffFiles, diffFilesFolder2, newerFiles) 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | } 67 | 68 | footer := figure.NewFigure("----------------------------", "eftitalic", true) 69 | footer.Print() 70 | fmt.Println() 71 | } 72 | 73 | // Check if all required environment variables are set and return the list of values 74 | func getEnvVariables() ([]string, error) { 75 | requiredEnvVars := []string{"REPO_URL", "REPO_FOLDER_1", "REPO_FOLDER_2", "TOKEN"} 76 | envVarValues := make([]string, len(requiredEnvVars)) 77 | 78 | for i, envVar := range requiredEnvVars { 79 | if value, exists := os.LookupEnv(envVar); !exists || value == "" { 80 | return nil, fmt.Errorf("missing environment variable ===> %s", envVar) 81 | } else { 82 | envVarValues[i] = value 83 | } 84 | } 85 | 86 | fmt.Println("\n[ ALL ENVIRONMENT VARIABLES ARE CONFIGURED ]") 87 | return envVarValues, nil 88 | } 89 | 90 | // Create a GitHub client using the provided token 91 | func createGitHubClient(token string) *github.Client { 92 | ctx := context.Background() 93 | ts := oauth2.StaticTokenSource( 94 | &oauth2.Token{AccessToken: token}, 95 | ) 96 | tc := oauth2.NewClient(ctx, ts) 97 | return github.NewClient(tc) 98 | } 99 | 100 | // Compare folders and get files present in folder1 but not in folder2, and files with newer commits in folder1 101 | // Compare folders and get files present in folder1 but not in folder2, 102 | // files with newer commits in folder1, and files present in folder2 but not in folder1 103 | func compareFolders(client *github.Client, repoURL, folder1, folder1Branch, folder2, folder2Branch string) ([]*github.RepositoryContent, []*github.RepositoryContent, []*github.RepositoryContent, error) { 104 | owner, repo := parseRepoURL(repoURL) 105 | 106 | // Get contents of folder1 and folder2 from the GitHub repository 107 | folder1Files, err := getFolderContents(client, owner, repo, folder1, folder1Branch) 108 | if err != nil { 109 | return nil, nil, nil, err 110 | } 111 | 112 | folder2Files, err := getFolderContents(client, owner, repo, folder2, folder2Branch) 113 | if err != nil { 114 | return nil, nil, nil, err 115 | } 116 | 117 | diffFilesFolder1 := make([]*github.RepositoryContent, 0) 118 | newerFiles := make([]*github.RepositoryContent, 0) 119 | diffFilesFolder2 := make([]*github.RepositoryContent, 0) 120 | 121 | var wg sync.WaitGroup 122 | wg.Add(len(folder1Files)) 123 | 124 | var mu sync.Mutex // Declare a mutex 125 | 126 | // Compare files in folder1 with files in folder2 concurrently using goroutines 127 | for _, file1 := range folder1Files { 128 | go func(file *github.RepositoryContent) { 129 | defer wg.Done() 130 | 131 | found := false 132 | for _, file2 := range folder2Files { 133 | if *file.Name == *file2.Name { 134 | found = true 135 | break 136 | } 137 | } 138 | 139 | if !found { 140 | mu.Lock() // Lock the mutex before modifying the slice 141 | diffFilesFolder1 = append(diffFilesFolder1, file) 142 | mu.Unlock() // Unlock the mutex after modifying the slice 143 | } else { 144 | commit1, err := getFileLastCommit(client, owner, repo, folder1, folder1Branch, *file.Name) 145 | if err != nil { 146 | log.Println(err) 147 | return 148 | } 149 | commit2, err := getFileLastCommit(client, owner, repo, folder2, folder2Branch, *file.Name) 150 | if err != nil { 151 | log.Println(err) 152 | return 153 | } 154 | if commit1 != nil && commit2 != nil && commit1.GetCommit().GetCommitter().GetDate().Time.After(commit2.GetCommit().GetCommitter().GetDate().Time) { 155 | newerFiles = append(newerFiles, file) 156 | } 157 | } 158 | }(file1) 159 | } 160 | 161 | // Compare files in folder2 with files in folder1 162 | for _, file2 := range folder2Files { 163 | found := false 164 | for _, file1 := range folder1Files { 165 | if *file2.Name == *file1.Name { 166 | found = true 167 | break 168 | } 169 | } 170 | if !found { 171 | diffFilesFolder2 = append(diffFilesFolder2, file2) 172 | } 173 | } 174 | 175 | wg.Wait() 176 | 177 | return diffFilesFolder1, newerFiles, diffFilesFolder2, nil 178 | } 179 | 180 | // Get contents of a folder from the GitHub repository 181 | func getFolderContents(client *github.Client, owner, repo, folder, branch string) ([]*github.RepositoryContent, error) { 182 | opt := &github.RepositoryContentGetOptions{ 183 | Ref: branch, 184 | } 185 | _, files, _, err := client.Repositories.GetContents(context.Background(), owner, repo, folder, opt) 186 | if err != nil { 187 | return nil, err 188 | } 189 | return files, nil 190 | } 191 | 192 | // Get the last commit of a file in a specific path on a particular branch 193 | func getFileLastCommit(client *github.Client, owner, repo, path, branch, file string) (*github.RepositoryCommit, error) { 194 | commits, _, err := client.Repositories.ListCommits(context.Background(), owner, repo, &github.CommitsListOptions{Path: path + "/" + file, SHA: branch}) 195 | if err != nil { 196 | return nil, err 197 | } 198 | if len(commits) > 0 { 199 | return commits[0], nil 200 | } 201 | return nil, nil 202 | } 203 | 204 | // Parse the repository URL and extract the owner and repository name 205 | func parseRepoURL(repoURL string) (string, string) { 206 | parts := strings.Split(repoURL, "/") 207 | owner := parts[len(parts)-2] 208 | repo := parts[len(parts)-1] 209 | repo = strings.TrimSuffix(repo, ".git") 210 | return owner, repo 211 | } 212 | 213 | // Print the files in lexicographic order 214 | func printFilesSorted(files []*github.RepositoryContent) { 215 | // Sort files by their names 216 | sort.Slice(files, func(i, j int) bool { 217 | return *files[i].Name < *files[j].Name 218 | }) 219 | 220 | // Print the sorted file names 221 | for _, file := range files { 222 | fmt.Println(*file.Name) 223 | } 224 | } 225 | 226 | // Open a synchronization issue on GitHub repository 227 | func openSyncIssue(client *github.Client, repoURL, folder1, folder2 string, diffFiles, diffFilesFolder2, newerFiles []*github.RepositoryContent) error { 228 | owner, repo := parseRepoURL(repoURL) 229 | // Check if need to create multiple issues 230 | if os.Getenv("MULTIPLE_ISSUES") == "true" { 231 | for _, file := range diffFiles { 232 | timestamp := time.Now().Format("2006-01-02 15:04:05") 233 | issueTitle := "Synchronization Issue [" + timestamp + "]: " + folder1 + " vs " + folder2 234 | issueBody := "## Synchronization Issue\n\n" + 235 | "Folder1: " + folder1 + "\n\n" + 236 | "Folder2: " + folder2 + "\n\n" + 237 | "### Files present in " + folder1 + " but not in " + folder2 + "\n" 238 | issueBody += "- " + *file.Name + "\n" 239 | issueRequest := &github.IssueRequest{ 240 | Title: &issueTitle, 241 | Body: &issueBody, 242 | } 243 | _, _, err := client.Issues.Create(context.Background(), owner, repo, issueRequest) 244 | if err != nil { 245 | return err 246 | } 247 | fmt.Println("\n[ SYNCHRONIZATION ISSUE OPENED ]") 248 | } 249 | for _, file := range newerFiles { 250 | timestamp := time.Now().Format("2006-01-02 15:04:05") 251 | issueTitle := "Synchronization Issue [" + timestamp + "]: " + folder1 + " vs " + folder2 252 | issueBody := "## Synchronization Issue\n\n" + 253 | "Folder1: " + folder1 + "\n\n" + 254 | "Folder2: " + folder2 + "\n\n" + 255 | "### Files present in both " + folder1 + " and " + folder2 + " with newer commits in " + folder1 + "\n" 256 | issueBody += "- " + *file.Name + "\n" 257 | issueRequest := &github.IssueRequest{ 258 | Title: &issueTitle, 259 | Body: &issueBody, 260 | } 261 | _, _, err := client.Issues.Create(context.Background(), owner, repo, issueRequest) 262 | if err != nil { 263 | return err 264 | } 265 | fmt.Println("\n[ SYNCHRONIZATION ISSUE OPENED ]") 266 | } 267 | for _, file := range diffFilesFolder2 { 268 | timestamp := time.Now().Format("2006-01-02 15:04:05") 269 | issueTitle := "Synchronization Issue [" + timestamp + "]: " + folder1 + " vs " + folder2 270 | issueBody := "## Synchronization Issue\n\n" + 271 | "Folder1: " + folder1 + "\n\n" + 272 | "Folder2: " + folder2 + "\n\n" + 273 | "### Files present in " + folder2 + " but not in " + folder1 + "\n" 274 | issueBody += "- " + *file.Name + "\n" 275 | issueRequest := &github.IssueRequest{ 276 | Title: &issueTitle, 277 | Body: &issueBody, 278 | } 279 | _, _, err := client.Issues.Create(context.Background(), owner, repo, issueRequest) 280 | if err != nil { 281 | return err 282 | } 283 | fmt.Println("\n[ SYNCHRONIZATION ISSUE OPENED ]") 284 | } 285 | } else { // create a single issue 286 | // Generate timestamp 287 | timestamp := time.Now().Format("2006-01-02 15:04:05") 288 | issueTitle := "Synchronization Issue [" + timestamp + "]: " + folder1 + " vs " + folder2 289 | issueBody := "## Synchronization Issue\n\n" + 290 | "Folder1: " + folder1 + "\n\n" + 291 | "Folder2: " + folder2 + "\n\n" + 292 | "### Files present in " + folder1 + " but not in " + folder2 + "\n" 293 | for _, file := range diffFiles { 294 | issueBody += "- " + *file.Name + "\n" 295 | } 296 | 297 | issueBody += "\n### Files present in both " + folder1 + " and " + folder2 + " with newer commits in " + folder1 + "\n" 298 | for _, file := range newerFiles { 299 | issueBody += "- " + *file.Name + "\n" 300 | } 301 | 302 | issueBody += "\n### Files present in " + folder2 + " but not in " + folder1 + "\n" 303 | for _, file := range diffFilesFolder2 { 304 | issueBody += "- " + *file.Name + "\n" 305 | } 306 | 307 | issueRequest := &github.IssueRequest{ 308 | Title: &issueTitle, 309 | Body: &issueBody, 310 | } 311 | 312 | _, _, err := client.Issues.Create(context.Background(), owner, repo, issueRequest) 313 | if err != nil { 314 | return err 315 | } 316 | 317 | fmt.Println("\n[ SYNCHRONIZATION ISSUE OPENED ]") 318 | return nil 319 | } 320 | return nil 321 | } 322 | --------------------------------------------------------------------------------