├── .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 | [](https://github.com/R3DRUN3/github-content-sync/actions/workflows/goaction.yaml)
3 | [](https://github.com/R3DRUN3/github-content-sync/actions/workflows/release.yaml)
4 | [](https://github.com/R3DRUN3/github-content-sync/actions/workflows/oci.yaml)
5 | [](https://github.com/R3DRUN3/github-content-sync/releases/latest)
6 | [](https://opensource.org/licenses/MIT)
7 | [](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 |
--------------------------------------------------------------------------------