chore(release): v0.2.1 #12

Open
kjuulh wants to merge 0 commits from cuddle-please/release into main
51 changed files with 4448 additions and 4511 deletions

View File

@ -1,2 +1,171 @@
kind: template
load: cuddle-rust-service-plan.yaml
kind: pipeline
name: default
type: docker
steps:
- name: build ci
image: rustlang/rust:nightly
volumes:
- name: ci
path: /mnt/ci
environment:
PKG_CONFIG_SYSROOT_DIR: "/"
CI_PREFIX: "/mnt/ci"
commands:
- set -e
- apt update
- apt install musl-tools pkg-config libssl-dev openssl build-essential musl-dev -y
- rustup target add x86_64-unknown-linux-musl
- cd ci
- cargo build --target=x86_64-unknown-linux-musl
#- cargo build -p ci
- mv target/x86_64-unknown-linux-musl/debug/ci "$CI_PREFIX/ci"
#- mv target/debug/ci $CI_PREFIX/ci
- name: load_secret
image: debian:buster-slim
volumes:
- name: ssh
path: /root/.ssh/
environment:
SSH_KEY:
from_secret: gitea_id_ed25519
commands:
- mkdir -p $HOME/.ssh/
- echo "$SSH_KEY" | base64 -d > $HOME/.ssh/id_ed25519
- name: build pr
image: kasperhermansen/cuddle:latest
pull: always
volumes:
- name: ssh
path: /root/.ssh/
- name: dockersock
path: /var/run
- name: ci
path: /mnt/ci
commands:
- eval `ssh-agent`
- chmod -R 600 ~/.ssh
- ssh-add
- echo "$DOCKER_PASSWORD" | docker login --password-stdin --username="$DOCKER_USERNAME" docker.io
- ldd $CI_PREFIX
- apk add git
- cuddle x ci:pr
environment:
DOCKER_BUILDKIT: 1
DOCKER_PASSWORD:
from_secret: docker_password
DOCKER_USERNAME:
from_secret: docker_username
CUDDLE_SECRETS_PROVIDER: 1password
CUDDLE_ONE_PASSWORD_DOT_ENV: ".env.ci"
CUDDLE_SSH_AGENT: "true"
CI_PREFIX: "/mnt/ci/ci"
CUDDLE_PLEASE_TOKEN:
from_secret: cuddle_please_token
OP_SERVICE_ACCOUNT_TOKEN:
from_secret: op_service_account_token
when:
event:
- pull_request
exclude:
- main
- master
depends_on:
- "load_secret"
- "build ci"
- name: build main
image: kasperhermansen/cuddle:latest
pull: always
volumes:
- name: ssh
path: /root/.ssh/
- name: dockersock
path: /var/run
- name: ci
path: /mnt/ci
commands:
- eval `ssh-agent`
- chmod -R 600 ~/.ssh
- ssh-add
- echo "$DOCKER_PASSWORD" | docker login --password-stdin --username="$DOCKER_USERNAME" docker.io
- ldd $CI_PREFIX
- apk add git
- cuddle x ci:main
environment:
DOCKER_BUILDKIT: 1
DOCKER_PASSWORD:
from_secret: docker_password
DOCKER_USERNAME:
from_secret: docker_username
CUDDLE_SECRETS_PROVIDER: 1password
CUDDLE_ONE_PASSWORD_DOT_ENV: ".env.ci"
CUDDLE_SSH_AGENT: "true"
CI_PREFIX: "/mnt/ci/ci"
CUDDLE_PLEASE_TOKEN:
from_secret: cuddle_please_token
OP_SERVICE_ACCOUNT_TOKEN:
from_secret: op_service_account_token
when:
event:
- push
branch:
- main
- master
exclude:
- pull_request
depends_on:
- "load_secret"
- "build ci"
- name: deploy release
image: kasperhermansen/cuddle:latest
pull: always
volumes:
- name: ssh
path: /root/.ssh/
- name: dockersock
path: /var/run
commands:
- eval `ssh-agent`
- chmod -R 600 ~/.ssh
- ssh-add
- cuddle x build:release:all
- cuddle x deploy:docs:preview
environment:
DOCKER_BUILDKIT: 1
CUDDLE_SECRETS_PROVIDER: 1password
CUDDLE_ONE_PASSWORD_DOT_ENV: ".env.ci"
CUDDLE_SSH_AGENT: "true"
CUDDLE_CI: "true"
CUDDLE_PLEASE_TOKEN:
from_secret: cuddle_please_token
OP_SERVICE_ACCOUNT_TOKEN:
from_secret: op_service_account_token
when:
event:
- tag
ref:
include:
- refs/tags/v*
depends_on:
- "load_secret"
- "build ci"
services:
- name: docker
image: docker:dind
privileged: true
volumes:
- name: dockersock
path: /var/run
volumes:
- name: ssh
temp: {}
- name: dockersock
temp: {}
- name: ci
temp: {}

4
.gitignore vendored
View File

@ -1,3 +1,3 @@
target/
.cuddle/
.env
.cuddle/
target/

66
CHANGELOG.md Normal file
View File

@ -0,0 +1,66 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.1] - 2024-04-01
### Fixed
- *(deps)* update all dependencies to v61
- *(deps)* update all dependencies
- *(deps)* update all dependencies
- *(deps)* update all dependencies to v58
- *(deps)* update all dependencies
- *(deps)* update rust crate futures to 0.3.30
- *(deps)* update all dependencies
- *(deps)* update all dependencies
- *(deps)* update all dependencies
- *(deps)* update all dependencies
- *(deps)* update rust crate futures to 0.3.29
- *(deps)* update all dependencies
- *(deps)* update all dependencies
- *(deps)* update all dependencies
## [0.2.0] - 2023-08-09
### Added
- *(github)* add github support
### Docs
- *(README)* update with github support
### Other
- *(app)* split the main command file into multiples
## [0.1.1] - 2023-08-08
### Docs
- *(README)* add motivation why this project should exist
### Other
- *(cuddle-please)* update
## [0.1.0] - 2023-08-08
### Added
- add basic ci
- add basic readme
- with main loop
- with initial cmd and server
### Docs
- *(README)* added some more milestones and fixed docker-compose
- add clarification about expectations and what milestones are missing
- add license
- clarification
### Other
- Merge pull request 'Configure Renovate' (#1) from renovate/configure into main
Reviewed-on: https://git.front.kjuulh.io/kjuulh/contractor/pulls/1
- Add renovate.json

3191
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +0,0 @@
[workspace]
members = ["crates/*"]
resolver = "2"
[workspace.dependencies]
contractor = { path = "crates/contractor" }
anyhow = { version = "1" }
tokio = { version = "1", features = ["full"] }
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3.18" }
clap = { version = "4", features = ["derive", "env"] }
dotenv = { version = "0.15" }
axum = { version = "0.7" }

7
LICENSE Normal file
View File

@ -0,0 +1,7 @@
Copyright 2023 Kasper J. Hermansen
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

152
README.md
View File

@ -1 +1,151 @@
# contractor
# Contractor - A renovate bot for gitea and github
Contractor is a chatops like bot, integrating with github/gitea issues, allowing
commands to trigger renovate runs.
```bash
/contractor refresh
```
Contractor runs in a regular docker image and uses the official renovate slim
image behind the scenes, this can be changed in the configuration.
![command](./assets/command.png)
<small>Do note that the contractor was run under a personal user, hence the same
user replied</small>
## Motivation
Renovate by default if hosted yourself, is neither sharded, or runs on a
cron-job cycle. This leaves a lot to be desired from a developers point of view.
As it may take quite a long time for renovate to revisit the pull-request again,
if there is a lot of repositories enabled.
This project intends to add an ad-hoc invocation of renovate for a single
repository, this enables developers to retrigger renovate whenever they want.
The project is built to be integrated with github and gitea (initially), and
work in its pull-request system, so when a renovate pr shows up, you can either
manually retrigger it, or enable any of the options in the renovate dashboard,
and retrigger.
## DISCLAIMER
The project is still 0.x.x As such the api is subject to change, and the
examples will probably be out of date. The below should be seen as an example of
what the project will look like once feature-complete.
## Milestones
- [x] 0.1.0
- Includes basic setup such as working server bot, and installation command,
automation is missing however. Also only gitea support for now, because this
is where the project initially is supposed to be in use.
- [x] 0.2.0
- Add GitHub support, only github app support for now. This means that install is not needed, because a github app will automatically receive webhooks if setup properly. docs are missing for this (tbd).
- [ ] 0.3.0
- Add Delegation support (not clustering, just delegation of renovate jobs)
- [ ] 0.4.0
- Slack integration
- [ ] 0.5.0
- Add api key support
## Getting started
First you need to pull and run the contractor image, docker is the preferred way
of execution, but golang is also available from source.
Docker compose is given as an example, but you're free to run using `docker run`
if you prefer.
See example for a ready-to-run image
```yaml
# file: docker-compose.yaml
version: "3"
services:
contractor:
image: docker.io/kjuulh/contractor:latest
restart: unless-stopped
commands:
- contractor server serve
volumes:
- "./templates/contractor:/mnt/config"
- "/var/run/docker.sock:/var/run/docker.sock"
env_file:
- .env
```
```bash
# file: .env
GITEA_RENOVATE_TOKEN=<gitea application token> # needs repo and pull request permissions
GITHUB_RENOVATE_TOKEN=<github personel access token> # needs repo and pull request permissions
GITHUB_COM_TOKEN=<github personel access token> # used for communication, doesn't need much
RENOVATE_SECRETS='{"HARBOR_SERVER_PASSWORD": "<whatever secret you need in your config>"}'
CONTRACTOR_API_KEY='<some sufficiently secret password used for webhooks to authenticate to your server>'
```
```json
// file: templates/contractor/config.json
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"platform": "gitea",
"extends": [
"config:base"
]
}
// Remember to fill out the options as you see fit, this is not a complete example
```
Use renovate secret for each `{{ secrets.HARBOR_SERVER_PASSWORD }}` in your
config, replace `HARBOR_SERVER_PASSWORD` with your own
And then run the server with: `docker compose up`
This has started the server, but github doesn't know that it needs to talk to
you yet.
As such host the server somewhere with a public hostname, such that github or
gitea webhooks can reach it, i.e. contractor.some-domain.com:9111
To install the webhook, either use the docker image, or download the cli from
source.
### CLI
To install the cli
```bash
go install git.front.kjuulh.io/kjuulh/contractor@latest
```
contractor will automatically read any .env file, so you can leave out the
secrets.
```bash
contractor install \
--owner kjuulh \
--repository contractor \
--url https://git.front.kjuulh.io/api/v1 \
--backend gitea
```
If you leave any of these out, contractor will prompt your for required values.
### Docker
You can also use docker for it.
```bash
docker compose run contractor \
install \
--owner kjuulh \
--repository contractor \
--url https://git.front.kjuulh.io/api/v1 \
--backend gitea
```
### GitHub App
TBD, this should automatically install the webhook for allowed repositories, I
just haven't gotten around to it yet. It is on the 0.3.0 Roadmap.

BIN
assets/command.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

1901
ci/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
ci/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "ci"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dagger-sdk = "*"
eyre = "*"
color-eyre = "*"
tokio = "1"
clap = {version = "4", features = ["derive"]}
futures = "0.3.30"
async-scoped = { version = "0.9.0", features = ["tokio", "use-tokio"] }
dotenv = "*"

537
ci/src/main.rs Normal file
View File

@ -0,0 +1,537 @@
use std::path::PathBuf;
use std::sync::Arc;
use clap::Args;
use clap::Parser;
use clap::Subcommand;
use clap::ValueEnum;
use dagger_sdk::Platform;
use dagger_sdk::QueryContainerOpts;
use crate::please_release::run_release_please;
#[derive(Parser, Clone)]
#[command(author, version, about, long_about = None, subcommand_required = true)]
pub struct Command {
#[command(subcommand)]
commands: Commands,
#[command(flatten)]
global: GlobalArgs,
}
#[derive(Subcommand, Clone)]
pub enum Commands {
PullRequest {
#[arg(long)]
image: String,
#[arg(long)]
tag: String,
#[arg(long)]
bin_name: String,
},
Main {
#[arg(long)]
image: String,
#[arg(long)]
tag: String,
#[arg(long)]
bin_name: String,
},
Release,
}
#[derive(Subcommand, Clone)]
pub enum LocalCommands {
Build {
#[arg(long, default_value = "debug")]
profile: BuildProfile,
#[arg(long)]
bin_name: String,
},
Test,
DockerImage {
#[arg(long)]
image: String,
#[arg(long)]
tag: String,
#[arg(long)]
bin_name: String,
},
PleaseRelease,
BuildDocs {},
}
#[derive(Debug, Clone, ValueEnum)]
pub enum BuildProfile {
Debug,
Release,
}
#[derive(Debug, Clone, Args)]
pub struct GlobalArgs {
#[arg(long, global = true, help_heading = "Global")]
dry_run: bool,
#[arg(long, global = true, help_heading = "Global")]
golang_builder_image: Option<String>,
#[arg(long, global = true, help_heading = "Global")]
production_image: Option<String>,
#[arg(long, global = true, help_heading = "Global")]
docker_image: Option<String>,
#[arg(long, global = true, help_heading = "Global")]
source: Option<PathBuf>,
#[arg(long, global = true, help_heading = "Global")]
docs_image: Option<String>,
#[arg(long, global = true, help_heading = "Global")]
docs_image_tag: Option<String>,
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
let _ = dotenv::dotenv();
let _ = color_eyre::install();
let client = dagger_sdk::connect().await?;
let cli = Command::parse();
match &cli.commands {
Commands::PullRequest {
image,
tag,
bin_name,
} => {
async fn test(client: Arc<dagger_sdk::Query>, cli: &Command, bin_name: &String) {
let args = &cli.global;
let base_image = base_golang_image(client.clone(), args, &None, bin_name)
.await
.unwrap();
test::execute(client.clone(), args, base_image)
.await
.unwrap();
}
async fn build(
client: Arc<dagger_sdk::Query>,
cli: &Command,
bin_name: &String,
image: &String,
tag: &String,
) {
let args = &cli.global;
build::build(client.clone(), args, bin_name, image, tag)
.await
.unwrap();
}
tokio::join!(
test(client.clone(), &cli, bin_name),
build(client.clone(), &cli, bin_name, image, tag),
);
}
Commands::Main {
image,
tag,
bin_name,
} => {
async fn test(client: Arc<dagger_sdk::Query>, cli: &Command, bin_name: &String) {
let args = &cli.global;
let base_image = base_golang_image(client.clone(), args, &None, bin_name)
.await
.unwrap();
test::execute(client.clone(), args, base_image)
.await
.unwrap();
}
async fn build(
client: Arc<dagger_sdk::Query>,
cli: &Command,
bin_name: &String,
image: &String,
tag: &String,
) {
let args = &cli.global;
build::build_and_deploy(client.clone(), args, bin_name, image, tag)
.await
.unwrap();
}
async fn cuddle_please(client: Arc<dagger_sdk::Query>, cli: &Command) {
run_release_please(client.clone(), &cli.global)
.await
.unwrap();
}
tokio::join!(
test(client.clone(), &cli, bin_name),
build(client.clone(), &cli, bin_name, image, tag),
cuddle_please(client.clone(), &cli)
);
}
Commands::Release => todo!(),
}
Ok(())
}
mod please_release {
use std::sync::Arc;
use crate::GlobalArgs;
pub async fn run_release_please(
client: Arc<dagger_sdk::Query>,
_args: &GlobalArgs,
) -> eyre::Result<()> {
let build_image = client
.container()
.from("kasperhermansen/cuddle-please:main-1691504183");
let src = client
.git_opts(
"https://git.front.kjuulh.io/kjuulh/contractor",
dagger_sdk::QueryGitOpts {
experimental_service_host: None,
keep_git_dir: Some(true),
},
)
.branch("main")
.tree();
let res = build_image
.with_secret_variable(
"CUDDLE_PLEASE_TOKEN",
client
.set_secret("CUDDLE_PLEASE_TOKEN", std::env::var("CUDDLE_PLEASE_TOKEN")?)
.id()
.await?,
)
.with_workdir("/mnt/app")
.with_directory(".", src.id().await?)
.with_exec(vec![
"git",
"remote",
"set-url",
"origin",
&format!(
"https://git:{}@git.front.kjuulh.io/kjuulh/contractor.git",
std::env::var("CUDDLE_PLEASE_TOKEN")?
),
])
.with_exec(vec![
"cuddle-please",
"release",
"--engine=gitea",
"--owner=kjuulh",
"--repo=contractor",
"--branch=main",
"--api-url=https://git.front.kjuulh.io",
"--log-level=debug",
]);
let exit_code = res.exit_code().await?;
if exit_code != 0 {
eyre::bail!("failed to run cuddle-please");
}
let please_out = res.stdout().await?;
println!("{please_out}");
let please_out = res.stderr().await?;
println!("{please_out}");
Ok(())
}
}
mod build {
use std::sync::Arc;
use dagger_sdk::Container;
use crate::{base_golang_image, get_base_debian_image, GlobalArgs};
pub async fn build_and_deploy(
client: Arc<dagger_sdk::Query>,
args: &GlobalArgs,
bin_name: &String,
image: &String,
tag: &String,
) -> eyre::Result<()> {
// let containers = vec!["linux/amd64", "linux/arm64"];
let base_image = get_base_debian_image(
client.clone(),
&args.clone(),
Some("linux/amd64".to_string()),
)
.await?;
let container = base_golang_image(
client.clone(),
args,
&Some("linux/amd64".to_string()),
&bin_name.clone(),
)
.await?;
let build_image = execute(client.clone(), args, &container, &base_image, bin_name).await?;
let build_id = build_image.id().await?;
let _container = client
.clone()
.container()
.publish_opts(
format!("{image}:{tag}"),
dagger_sdk::ContainerPublishOpts {
platform_variants: Some(vec![build_id]),
},
)
.await?;
Ok(())
}
pub async fn build(
client: Arc<dagger_sdk::Query>,
args: &GlobalArgs,
bin_name: &String,
_image: &String,
_tag: &String,
) -> eyre::Result<()> {
// let containers = vec!["linux/amd64", "linux/arm64"];
let base_image = get_base_debian_image(
client.clone(),
&args.clone(),
Some("linux/amd64".to_string()),
)
.await?;
let container = base_golang_image(
client.clone(),
args,
&Some("linux/amd64".to_string()),
&bin_name.clone(),
)
.await?;
let build_image = execute(client.clone(), args, &container, &base_image, bin_name).await?;
build_image.exit_code().await?;
Ok(())
}
pub async fn execute(
_client: Arc<dagger_sdk::Query>,
_args: &GlobalArgs,
container: &dagger_sdk::Container,
base_image: &dagger_sdk::Container,
bin_name: &String,
) -> eyre::Result<Container> {
let final_image = base_image
.with_file(
format!("/usr/local/bin/{}", &bin_name),
container
.file(format!("/mnt/src/dist/{}", &bin_name))
.id()
.await?,
)
.with_exec(vec![bin_name, "--help"]);
let output = final_image.stdout().await?;
println!("{output}");
Ok(final_image)
}
}
mod test {
use std::sync::Arc;
use crate::GlobalArgs;
pub async fn execute(
_client: Arc<dagger_sdk::Query>,
args: &GlobalArgs,
container: dagger_sdk::Container,
) -> eyre::Result<()> {
let test_image = container
.pipeline("test")
.with_exec(vec!["go", "test", "./..."]);
test_image.exit_code().await?;
Ok(())
}
}
pub async fn get_base_docker_image(
client: Arc<dagger_sdk::Query>,
args: &GlobalArgs,
platform: Option<String>,
) -> eyre::Result<dagger_sdk::Container> {
let default_platform = client.default_platform().await?;
let platform = platform.map(Platform).unwrap_or(default_platform);
let image = client
.container_opts(QueryContainerOpts {
id: None,
platform: Some(platform),
})
.from(
args.docker_image
.clone()
.unwrap_or("docker:dind".to_string()),
);
Ok(image)
}
pub async fn get_base_debian_image(
client: Arc<dagger_sdk::Query>,
args: &GlobalArgs,
platform: Option<String>,
) -> eyre::Result<dagger_sdk::Container> {
let docker_image = get_base_docker_image(client.clone(), args, platform.clone()).await?;
let default_platform = client.default_platform().await?;
let platform = platform.map(Platform).unwrap_or(default_platform);
let image = client
.container_opts(QueryContainerOpts {
id: None,
platform: Some(platform),
})
.from(
args.production_image
.clone()
.unwrap_or("alpine:latest".to_string()),
);
let base_image = image
.with_exec(vec!["apk", "add", "openssl", "openssl-dev", "pkgconfig"])
.with_file(
"/usr/local/bin/docker",
docker_image.file("/usr/local/bin/docker").id().await?,
);
Ok(base_image)
}
pub fn get_src(
client: Arc<dagger_sdk::Query>,
args: &GlobalArgs,
) -> eyre::Result<dagger_sdk::Directory> {
let directory = client.host().directory_opts(
args.source
.clone()
.unwrap_or(PathBuf::from("."))
.display()
.to_string(),
dagger_sdk::HostDirectoryOptsBuilder::default()
.exclude(vec![
"node_modules/",
".git/",
"target/",
".cuddle/",
"docs/",
"ci/",
])
.build()?,
);
Ok(directory)
}
pub async fn get_golang_dep_src(
client: Arc<dagger_sdk::Query>,
args: &GlobalArgs,
) -> eyre::Result<dagger_sdk::Directory> {
let directory = client.host().directory_opts(
args.source
.clone()
.unwrap_or(PathBuf::from("."))
.display()
.to_string(),
dagger_sdk::HostDirectoryOptsBuilder::default()
.include(vec!["**/go.*"])
.build()?,
);
Ok(directory)
}
pub async fn base_golang_image(
client: Arc<dagger_sdk::Query>,
args: &GlobalArgs,
platform: &Option<String>,
bin_name: &String,
) -> eyre::Result<dagger_sdk::Container> {
let dep_src = get_golang_dep_src(client.clone(), args).await?;
let src = get_src(client.clone(), args)?;
let client = client.pipeline("golang_base_image");
let goarch = match platform
.clone()
.unwrap_or("linux/amd64".to_string())
.as_str()
{
"linux/amd64" => "amd64",
"linux/arm64" => "arm64",
_ => eyre::bail!("architecture not supported"),
};
let goos = match platform
.clone()
.unwrap_or("linux/amd64".to_string())
.as_str()
{
"linux/amd64" => "linux",
"linux/arm64" => "linux",
_ => eyre::bail!("os not supported"),
};
let golang_build_image = client
.container()
.from(
args.golang_builder_image
.as_ref()
.unwrap_or(&"golang:latest".into()),
)
.with_env_variable("GOOS", goos)
.with_env_variable("GOARCH", goarch)
.with_env_variable("CGO_ENABLED", "0");
let golang_dep_download = golang_build_image
.with_directory("/mnt/src", dep_src.id().await?)
.with_exec(vec!["go", "mod", "download"])
.with_mounted_cache(
"/root/go",
client.cache_volume("golang_mod_cache").id().await?,
);
let golang_bin = golang_build_image
.with_workdir("/mnt/src")
// .with_directory(
// "/root/go",
// golang_dep_download.directory("/root/go").id().await?,
// )
.with_directory("/mnt/src/", src.id().await?)
.with_exec(vec![
"go",
"build",
"-o",
&format!("dist/{bin_name}"),
"main.go",
]);
golang_bin.exit_code().await?;
Ok(golang_bin)
}

147
cmd/contractor/main.go Normal file
View File

@ -0,0 +1,147 @@
package contractor
import (
"log"
"github.com/gin-gonic/gin"
"github.com/spf13/cobra"
"git.front.kjuulh.io/kjuulh/contractor/internal/bot"
"git.front.kjuulh.io/kjuulh/contractor/internal/features"
"git.front.kjuulh.io/kjuulh/contractor/internal/providers"
"git.front.kjuulh.io/kjuulh/contractor/internal/queue"
"git.front.kjuulh.io/kjuulh/contractor/internal/renovate"
)
func installCmd() *cobra.Command {
var (
owner string
repository string
serverType string
url string
token string
)
cmd := &cobra.Command{
Use: "install",
Run: func(cmd *cobra.Command, args []string) {
if err := providers.NewGiteaClient(&url, &token).CreateWebhook(owner, repository); err != nil {
log.Printf("failed to add create webhook: %s", err.Error())
}
},
}
cmd.Flags().StringVarP(&owner, "owner", "o", "", "the owner for which the repository belongs")
cmd.Flags().StringVarP(&repository, "repository", "p", "", "the repository to install")
cmd.Flags().
StringVar(&serverType, "server-type", "gitea", "the server type to use [gitea, github]")
cmd.MarkFlagRequired("owner")
cmd.MarkFlagRequired("repository")
cmd.PersistentFlags().StringVar(&url, "url", "", "the api url of the server")
cmd.PersistentFlags().StringVar(&token, "token", "", "the token to authenticate with")
return cmd
}
func serverCmd() *cobra.Command {
var (
url string
token string
githubAppID int64
githubInstallationID int64
githubPrivateKeyPath string
)
giteaClient := providers.NewGiteaClient(&url, &token)
githubClient := providers.NewGitHubClient(&githubAppID, &githubInstallationID, &githubPrivateKeyPath)
renovateClient := renovate.NewRenovateClient("")
queue := queue.NewGoQueue()
botHandler := bot.NewBotHandler(giteaClient, githubClient)
giteaWebhook := features.NewGiteaWebhook(botHandler, queue)
githubWebhook := features.NewGitHubWebhook(botHandler, queue)
features.RegisterGiteaQueues(queue, renovateClient, giteaClient)
features.RegisterGitHubQueues(queue, renovateClient, githubClient)
cmd := &cobra.Command{
Use: "server",
}
cmd.PersistentFlags().StringVar(&url, "url", "", "the api url of the server")
cmd.PersistentFlags().StringVar(&token, "token", "", "the token to authenticate with")
cmd.PersistentFlags().Int64Var(&githubAppID, "github-app-id", 0, "github app id to authenticate with")
cmd.PersistentFlags().Int64Var(&githubInstallationID, "github-installation-id", 0, "github installation id to authenticate with")
cmd.PersistentFlags().StringVar(&githubPrivateKeyPath, "github-private-key-path", "", "path to the github app private key")
cmd.AddCommand(serverServeCmd(&url, &token, giteaWebhook, githubWebhook))
return cmd
}
func serverServeCmd(
url *string,
token *string,
giteaWebhook *features.GiteaWebhook,
githubWebhook *features.GitHubWebhook,
) *cobra.Command {
cmd := &cobra.Command{
Use: "serve",
Run: func(cmd *cobra.Command, args []string) {
engine := gin.Default()
github := engine.Group("/github")
{
github.POST("/webhook", func(ctx *gin.Context) {
var request features.GitHubWebhookRequest
if err := ctx.BindJSON(&request); err != nil {
ctx.AbortWithError(500, err)
return
}
if err := githubWebhook.HandleGitHubWebhook(ctx.Request.Context(), &request); err != nil {
ctx.AbortWithError(500, err)
return
}
ctx.Status(204)
})
}
gitea := engine.Group("/gitea")
{
gitea.POST("/webhook", func(ctx *gin.Context) {
var request features.GiteaWebhookRequest
if err := ctx.BindJSON(&request); err != nil {
ctx.AbortWithError(500, err)
return
}
if err := giteaWebhook.HandleGiteaWebhook(ctx.Request.Context(), &request); err != nil {
ctx.AbortWithError(500, err)
return
}
ctx.Status(204)
})
}
engine.Run("0.0.0.0:9111")
},
}
return cmd
}
func RootCmd() *cobra.Command {
cmd := &cobra.Command{Use: "contractor"}
cmd.AddCommand(installCmd(), serverCmd())
return cmd
}

View File

@ -1 +0,0 @@
/target

View File

@ -1,25 +0,0 @@
[package]
name = "contractor"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
dotenv.workspace = true
axum.workspace = true
serde = { version = "1.0.202", features = ["derive"] }
sqlx = { version = "0.7.4", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "time"] }
uuid = { version = "1.8.0", features = ["v4"] }
tower-http = { version = "0.5.2", features = ["cors", "trace"] }
futures = "0.3.30"
reqwest = {version = "0.12.4", default-features = false, features = ["json", "rustls-tls"]}
itertools = "0.13.0"
regex = "1.10.4"
serde_json = "1.0.117"
dagger-sdk = "0.9.8"
backon = "0.4.4"

View File

@ -1 +0,0 @@
-- Add migration script here

View File

@ -1,137 +0,0 @@
use std::net::SocketAddr;
use anyhow::Context;
use axum::{
extract::{MatchedPath, State},
http::Request,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use tower_http::trace::TraceLayer;
use crate::{
services::{
bot::{BotRequest, BotState},
gitea::Repository,
},
SharedState,
};
pub async fn serve_axum(state: &SharedState, host: &SocketAddr) -> Result<(), anyhow::Error> {
tracing::info!("running webhook server");
let app = Router::new()
.route("/", get(root))
.route("/webhooks/gitea", post(gitea_webhook))
.with_state(state.to_owned())
.layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
// Log the matched route's path (with placeholders not filled in).
// Use request.uri() or OriginalUri if you want the real path.
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
tracing::info_span!(
"http_request",
method = ?request.method(),
matched_path,
some_other_field = tracing::field::Empty,
)
}), // ...
);
tracing::info!("listening on {}", host);
let listener = tokio::net::TcpListener::bind(host).await.unwrap();
axum::serve(listener, app.into_make_service()).await?;
Ok(())
}
async fn root() -> &'static str {
"Hello, contractor!"
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GiteaWebhookComment {
body: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GiteaWebhookRepository {
full_name: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(untagged)]
pub enum GiteaWebhook {
Issue {
comment: GiteaWebhookComment,
repository: GiteaWebhookRepository,
},
}
pub enum ApiError {
InternalError(anyhow::Error),
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
match self {
ApiError::InternalError(e) => {
tracing::error!("failed with internal error: {}", e);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}
}
.into_response()
}
}
async fn gitea_webhook(
State(state): State<SharedState>,
Json(json): Json<GiteaWebhook>,
) -> Result<impl IntoResponse, ApiError> {
tracing::info!(
"called: {}",
serde_json::to_string(&json)
.context("failed to serialize webhook")
.map_err(ApiError::InternalError)?
);
let bot_req: BotRequest = json.try_into().map_err(ApiError::InternalError)?;
state
.bot()
.handle_request(bot_req)
.await
.map_err(ApiError::InternalError)?;
Ok("Hello, contractor!")
}
impl TryFrom<GiteaWebhook> for BotRequest {
type Error = anyhow::Error;
fn try_from(value: GiteaWebhook) -> Result<Self, Self::Error> {
match value {
GiteaWebhook::Issue {
comment,
repository,
} => {
let (owner, name) = repository.full_name.split_once('/').ok_or(anyhow::anyhow!(
"{} did not contain a valid owner/repository",
&repository.full_name
))?;
Ok(BotRequest {
repo: Repository {
owner: owner.into(),
name: name.into(),
},
command: comment.body,
})
}
}
}
}

View File

@ -1,97 +0,0 @@
use std::{net::SocketAddr, sync::Arc};
use clap::{Parser, Subcommand};
use futures::{stream::FuturesUnordered, StreamExt};
use tokio::task;
#[derive(Parser)]
#[command(author, version, about, long_about = None, subcommand_required = true)]
struct Command {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Serve {
#[arg(env = "SERVICE_HOST", long, default_value = "127.0.0.1:3000")]
host: SocketAddr,
},
Reconcile {
#[arg(long)]
user: Option<String>,
#[arg(long)]
org: Option<Vec<String>>,
#[arg(long, env = "CONTRACTOR_FILTER")]
filter: Option<String>,
#[arg(long = "force-refresh", env = "CONTRACTOR_FORCE_REFRESH")]
force_refresh: bool,
},
}
mod api;
mod schedule;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
tracing_subscriber::fmt::init();
let cli = Command::parse();
match cli.command {
Some(Commands::Serve { host }) => {
tracing::info!("Starting service");
let state = SharedState::from(Arc::new(State::new().await?));
let mut tasks = FuturesUnordered::new();
tasks.push({
let state = state.clone();
task::spawn(async move {
serve_axum(&state, &host).await?;
Ok::<(), anyhow::Error>(())
})
});
tasks.push(task::spawn(async move {
serve_cron_jobs(&state).await?;
Ok::<(), anyhow::Error>(())
}));
while let Some(result) = tasks.next().await {
result??
}
}
Some(Commands::Reconcile {
user,
org,
filter,
force_refresh,
}) => {
tracing::info!("running reconcile");
let state = SharedState::from(Arc::new(State::new().await?));
state
.reconciler()
.reconcile(user, org, filter, force_refresh)
.await?;
tracing::info!("done running reconcile");
}
None => {}
}
Ok(())
}
mod state;
pub use crate::state::{SharedState, State};
use crate::{api::serve_axum, schedule::serve_cron_jobs, services::reconciler::ReconcilerState};
mod services;

View File

@ -1,16 +0,0 @@
use crate::SharedState;
pub async fn serve_cron_jobs(state: &SharedState) -> Result<(), anyhow::Error> {
let _state = state.clone();
tokio::spawn(async move {
loop {
tracing::info!("running cronjobs");
tokio::time::sleep(std::time::Duration::from_secs(10_000)).await;
}
Ok::<(), anyhow::Error>(())
})
.await??;
Ok(())
}

View File

@ -1,5 +0,0 @@
pub mod bot;
pub mod engines;
pub mod gitea;
pub mod reconciler;
pub mod renovate;

View File

@ -1,84 +0,0 @@
use clap::{Parser, Subcommand};
use crate::{services::renovate::RenovateConfig, SharedState};
use super::{engines::dagger::Dagger, gitea::Repository};
pub struct Bot {
command_name: String,
dagger: Dagger,
}
#[derive(Parser)]
#[command(author, version, about, long_about = None, subcommand_required = true)]
struct BotCommand {
#[command(subcommand)]
command: Option<BotCommands>,
}
#[derive(Subcommand)]
enum BotCommands {
Refresh {
#[arg(long)]
all: bool,
},
}
impl Bot {
pub fn new(dagger: Dagger) -> Self {
Self {
command_name: std::env::var("CONTRACTOR_COMMAND_NAME").unwrap_or("contractor".into()),
dagger,
}
}
pub async fn handle_request(&self, req: impl Into<BotRequest>) -> anyhow::Result<()> {
let req: BotRequest = req.into();
if !req.command.starts_with(&self.command_name) {
return Ok(());
}
let cmd = BotCommand::parse_from(req.command.split_whitespace());
match cmd.command {
Some(BotCommands::Refresh { all }) => {
tracing::info!("triggering refresh for: {}, all: {}", req.repo, all);
let dagger = self.dagger.clone();
tokio::spawn(async move {
match dagger
.execute_renovate(&RenovateConfig {
repo: format!("{}/{}", &req.repo.owner, &req.repo.name),
})
.await
{
Ok(_) => {}
Err(e) => tracing::error!("failed to execute renovate: {}", e),
};
});
}
None => {
// TODO: Send back the help menu
}
}
Ok(())
}
}
pub struct BotRequest {
pub repo: Repository,
pub command: String,
}
pub trait BotState {
fn bot(&self) -> Bot;
}
impl BotState for SharedState {
fn bot(&self) -> Bot {
Bot::new(self.engine.clone())
}
}

View File

@ -1 +0,0 @@
pub mod dagger;

View File

@ -1,157 +0,0 @@
use std::{str::FromStr, sync::Arc};
use dagger_sdk::ContainerWithNewFileOptsBuilder;
use futures::Future;
use tokio::sync::RwLock;
type DynDagger = Arc<dyn traits::Dagger + Send + Sync + 'static>;
#[derive(Clone)]
pub struct Dagger {
dagger: DynDagger,
}
impl Default for Dagger {
fn default() -> Self {
Self::new()
}
}
impl Dagger {
pub fn new() -> Self {
Self {
dagger: Arc::new(DefaultDagger::new()),
}
}
}
impl std::ops::Deref for Dagger {
type Target = DynDagger;
fn deref(&self) -> &Self::Target {
&self.dagger
}
}
struct DefaultDagger {
client: Arc<RwLock<Option<dagger_sdk::Query>>>,
}
impl DefaultDagger {
pub fn new() -> Self {
let client = Arc::new(RwLock::new(None));
let host =
std::env::var("CONTRACTOR_DOCKER_HOST").expect("CONTRACTOR_DOCKER_HOST to be set");
std::env::set_var("DOCKER_HOST", host);
tokio::spawn({
let client = client.clone();
async move {
let mut client = client.write().await;
match dagger_sdk::connect().await {
Ok(o) => *client = Some(o),
Err(e) => tracing::error!("failed to start dagger engine: {}", e),
};
}
});
Self { client }
}
pub async fn get_client(&self) -> dagger_sdk::Query {
let client = self.client.clone().read().await.clone();
client.unwrap()
}
}
impl traits::Dagger for DefaultDagger {
fn execute_renovate<'a>(
&'a self,
config: &'a crate::services::renovate::RenovateConfig,
) -> std::pin::Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
Box::pin(async move {
let renovate_image = "renovate/renovate:37";
let client = self.get_client().await;
let github_com_token = client.set_secret(
"GITHUB_COM_TOKEN",
std::env::var("CONTRACTOR_GITHUB_COM_TOKEN")
.expect("CONTRACTOR_GITHUB_COM_TOKEN to be set"),
);
let renovate_secrets = client.set_secret(
"RENOVATE_SECRETS",
std::env::var("CONTRACTOR_RENOVATE_SECRETS")
.expect("CONTRACTOR_RENOVATE_SECRETS to be set"),
);
let renovate_token = client.set_secret(
"RENOVATE_TOKEN",
std::env::var("CONTRACTOR_RENOVATE_TOKEN")
.expect("CONTRACTOR_RENOVATE_TOKEN to be set"),
);
let renovate_file_url = std::env::var("CONTRACTOR_RENOVATE_CONFIG_URL")
.expect("CONTRACTOR_RENOVATE_CONFIG_URL to be set");
let renovate_file = client.http(renovate_file_url).contents().await?;
let mut renovate_file_value: serde_json::Value = serde_json::from_str(&renovate_file)?;
let obj = renovate_file_value
.as_object_mut()
.ok_or(anyhow::anyhow!("config is not a valid json object"))?;
let _ = obj.insert("autodiscover".into(), serde_json::Value::from_str("false")?);
let renovate_file = serde_json::to_string(&obj)?;
let output = client
.container()
.from(renovate_image)
.with_secret_variable("GITHUB_COM_TOKEN", github_com_token)
.with_secret_variable("RENOVATE_SECRETS", renovate_secrets)
.with_secret_variable("RENOVATE_TOKEN", renovate_token)
.with_env_variable("LOG_LEVEL", "info")
.with_env_variable("RENOVATE_CONFIG_FILE", "/opt/renovate/config.json")
.with_new_file_opts(
"/opt/renovate/config.json",
ContainerWithNewFileOptsBuilder::default()
.contents(renovate_file.as_str())
.permissions(0o644isize)
.build()?,
)
.with_exec(vec![&config.repo])
.stdout()
.await?;
tracing::debug!(
"renovate on: {} finished with output {}",
&config.repo,
&output
);
Ok::<(), anyhow::Error>(())
})
}
}
pub mod traits {
use std::pin::Pin;
use futures::Future;
use crate::services::renovate::RenovateConfig;
pub trait Dagger {
fn execute_renovate<'a>(
&'a self,
config: &'a RenovateConfig,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>>;
}
}

View File

@ -1,508 +0,0 @@
use std::{fmt::Display, ops::Deref, pin::Pin, sync::Arc};
type DynGiteaClient = Arc<dyn traits::GiteaClient + Send + Sync + 'static>;
pub struct GiteaClient(DynGiteaClient);
impl GiteaClient {
pub fn new() -> Self {
Self(Arc::new(DefaultGiteaClient::default()))
}
}
impl Deref for GiteaClient {
type Target = DynGiteaClient;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Repository {
pub owner: String,
pub name: String,
}
impl Display for Repository {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{}/{}", self.owner, self.name))
}
}
impl TryFrom<GiteaRepository> for Repository {
type Error = anyhow::Error;
fn try_from(value: GiteaRepository) -> Result<Self, Self::Error> {
let (owner, name) = value
.full_name
.split_once('/')
.ok_or(anyhow::anyhow!(
"name of repository is invalid, should contain a /"
))
.map_err(|e| {
tracing::warn!("failed to parse repository: {}", e);
e
})?;
Ok(Repository {
owner: owner.into(),
name: name.into(),
})
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct GiteaRepository {
full_name: String,
}
pub struct DefaultGiteaClient {
url: String,
token: String,
webhook_url: String,
}
impl Default for DefaultGiteaClient {
fn default() -> Self {
Self {
url: std::env::var("GITEA_URL")
.context("GITEA_URL should be set")
.map(|g| g.trim_end_matches('/').to_string())
.unwrap(),
token: std::env::var("GITEA_TOKEN")
.context("GITEA_TOKEN should be set")
.unwrap(),
webhook_url: std::env::var("CONTRACTOR_URL")
.context("CONTRACTOR_URL should be set")
.map(|url| format!("{}/webhooks/gitea", url.trim_end_matches('/')))
.unwrap(),
}
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct GiteaWebhook {
id: isize,
#[serde(rename = "type")]
r#type: GiteaWebhookType,
config: GiteaWebhookConfig,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GiteaWebhookConfig {
url: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub enum GiteaWebhookType {
#[serde(rename = "gitea")]
Gitea,
Other(String),
}
#[derive(Clone, Debug, Serialize)]
pub struct CreateGiteaWebhook {
active: bool,
authorization_header: Option<String>,
branch_filter: Option<String>,
config: CreateGiteaWebhookConfig,
events: Vec<String>,
#[serde(rename = "type")]
r#type: GiteaWebhookType,
}
#[derive(Clone, Debug, Serialize)]
pub struct CreateGiteaWebhookConfig {
content_type: String,
url: String,
}
impl DefaultGiteaClient {
async fn fetch_user_repos_page(
&self,
page: usize,
) -> anyhow::Result<(Vec<Repository>, Vec<usize>)> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/user/repos?page={page}&limit=50", self.url);
tracing::trace!("calling url: {}", &url);
let response = client
.get(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("token {}", self.token))
.send()
.await?;
let mut pages = Vec::new();
if page <= 1 {
if let Some(link_header) = response.headers().get("link") {
let link_str = link_header.to_str()?;
pages = parse_link(page, link_str)?;
}
}
let repositories = response.json::<Vec<GiteaRepository>>().await?;
Ok((
repositories
.into_iter()
.flat_map(Repository::try_from)
.collect(),
pages,
))
}
pub async fn fetch_user_repos(&self) -> anyhow::Result<Vec<Repository>> {
let (repos, pages) = self.fetch_user_repos_page(1).await?;
let tasks = pages
.into_iter()
.map(|page| async move {
let (new_repos, _) = self.fetch_user_repos_page(page).await?;
Ok::<Vec<Repository>, anyhow::Error>(new_repos)
})
.collect::<FuturesUnordered<_>>();
let res: Result<Vec<Vec<Repository>>, anyhow::Error> = tasks.try_collect().await;
let res = res?.into_iter().flatten();
Ok(repos.into_iter().chain(res).collect())
}
async fn fetch_org_repos_page(
&self,
org: &str,
page: usize,
) -> anyhow::Result<(Vec<Repository>, Vec<usize>)> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/v1/orgs/{}/repos?page={page}&limit=50",
self.url, org
);
tracing::trace!("calling url: {}", &url);
let response = client
.get(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("token {}", self.token))
.send()
.await?;
let mut pages = Vec::new();
if page <= 1 {
if let Some(link_header) = response.headers().get("link") {
let link_str = link_header.to_str()?;
pages = parse_link(page, link_str)?;
}
}
let repositories = response.json::<Vec<GiteaRepository>>().await?;
Ok((
repositories
.into_iter()
.flat_map(Repository::try_from)
.collect(),
pages,
))
}
pub async fn fetch_org_repos(&self, org: &str) -> anyhow::Result<Vec<Repository>> {
let (repos, pages) = self.fetch_org_repos_page(org, 1).await?;
let tasks = pages
.into_iter()
.map(|page| async move {
let (new_repos, _) = self.fetch_org_repos_page(org, page).await?;
Ok::<Vec<Repository>, anyhow::Error>(new_repos)
})
.collect::<FuturesUnordered<_>>();
let res: Result<Vec<Vec<Repository>>, anyhow::Error> = tasks.try_collect().await;
let res = res?.into_iter().flatten();
Ok(repos.into_iter().chain(res).collect())
}
async fn fetch_renovate(&self, repo: &Repository) -> anyhow::Result<Option<()>> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/v1/repos/{}/{}/contents/renovate.json",
self.url, &repo.owner, &repo.name
);
tracing::trace!("calling url: {}", &url);
let response = (|| async {
client
.get(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("token {}", self.token))
.send()
.await
})
.retry(&ExponentialBuilder::default())
.notify(|err, dur| {
tracing::debug!("retrying job: {err}, in: {} seconds", dur.as_secs());
})
.await?;
match response.error_for_status() {
Ok(_) => Ok(Some(())),
Err(e) => match e.status() {
Some(StatusCode::NOT_FOUND) => Ok(None),
Some(status) => {
tracing::warn!(
"failed to call fetch renovate for: {}, with error: {}",
&repo,
status
);
anyhow::bail!(e)
}
_ => {
anyhow::bail!(e)
}
},
}
}
async fn get_webhook(&self, repo: &Repository) -> anyhow::Result<Option<GiteaWebhook>> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/v1/repos/{}/{}/hooks",
self.url, &repo.owner, &repo.name
);
tracing::trace!("calling url: {}", &url);
let response = (|| async {
client
.get(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("token {}", self.token))
.send()
.await
})
.retry(&ExponentialBuilder::default())
.notify(|err, dur| {
tracing::debug!("retrying job: {err}, in: {} seconds", dur.as_secs());
})
.await?;
let webhooks = response.json::<Vec<GiteaWebhook>>().await?;
let valid_webhooks = webhooks
.into_iter()
.filter(|w| w.r#type == GiteaWebhookType::Gitea)
.filter(|w| w.config.url.contains("contractor"))
.collect::<Vec<_>>();
Ok(valid_webhooks.first().map(|f| f.to_owned()))
}
async fn add_webhook(&self, repo: &Repository) -> anyhow::Result<()> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/v1/repos/{}/{}/hooks",
self.url, &repo.owner, &repo.name
);
let val = self.create_webhook();
tracing::trace!(
"calling url: {} with body {}",
&url,
serde_json::to_string(&val)?
);
let response = (|| async {
client
.post(&url)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Authorization", format!("token {}", self.token))
.json(&val)
.send()
.await
})
.retry(&ExponentialBuilder::default())
.notify(|err, dur| {
tracing::debug!("retrying job: {err}, in: {} seconds", dur.as_secs());
})
.await?;
if let Err(e) = response.error_for_status_ref() {
if let Ok(ok) = response.text().await {
anyhow::bail!("failed to create webhook: {}, body: {}", e, ok);
}
anyhow::bail!("failed to create webhook: {}", e)
}
Ok(())
}
fn create_webhook(&self) -> CreateGiteaWebhook {
CreateGiteaWebhook {
active: true,
authorization_header: Some("something".into()),
branch_filter: Some("*".into()),
config: CreateGiteaWebhookConfig {
content_type: "json".into(),
url: format!("{}?type=contractor", self.webhook_url),
},
events: vec!["pull_request_comment".into(), "issue_comment".into()],
r#type: GiteaWebhookType::Gitea,
}
}
async fn update_webhook(&self, repo: &Repository, webhook: GiteaWebhook) -> anyhow::Result<()> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/v1/repos/{}/{}/hooks/{}",
self.url, &repo.owner, &repo.name, &webhook.id,
);
let val = self.create_webhook();
tracing::trace!(
"calling url: {} with body {}",
&url,
serde_json::to_string(&val)?
);
let response = (|| async {
client
.patch(&url)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Authorization", format!("token {}", self.token))
.json(&val)
.send()
.await
})
.retry(&ExponentialBuilder::default())
.notify(|err, dur| {
tracing::debug!("retrying job: {err}, in: {} seconds", dur.as_secs());
})
.await?;
if let Err(e) = response.error_for_status_ref() {
if let Ok(ok) = response.text().await {
anyhow::bail!("failed to create webhook: {}, body: {}", e, ok);
}
anyhow::bail!("failed to create webhook: {}", e)
}
Ok(())
}
}
impl traits::GiteaClient for DefaultGiteaClient {
fn get_user_repositories<'a>(
&'a self,
user: &str,
) -> Pin<Box<dyn futures::prelude::Future<Output = anyhow::Result<Vec<Repository>>> + Send + 'a>>
{
tracing::debug!("fetching gitea repositories for user: {user}");
Box::pin(async { self.fetch_user_repos().await })
}
fn get_org_repositories<'a>(
&'a self,
org: &'a str,
) -> Pin<Box<dyn futures::prelude::Future<Output = anyhow::Result<Vec<Repository>>> + Send + 'a>>
{
tracing::debug!("fetching gitea repositories for org: {org}");
Box::pin(async move { self.fetch_org_repos(org).await })
}
fn renovate_enabled<'a>(
&'a self,
repo: &'a Repository,
) -> Pin<Box<dyn futures::prelude::Future<Output = anyhow::Result<bool>> + Send + 'a>> {
tracing::trace!("checking whether renovate is enabled for: {:?}", repo);
Box::pin(async { self.fetch_renovate(repo).await.map(|s| s.is_some()) })
}
fn ensure_webhook<'a>(
&'a self,
repo: &'a Repository,
force_refresh: bool,
) -> Pin<Box<dyn futures::prelude::Future<Output = anyhow::Result<()>> + Send + 'a>> {
tracing::trace!("ensuring webhook exists for repo: {}", repo);
Box::pin(async move {
match (self.get_webhook(repo).await?, force_refresh) {
(Some(_), false) => {
tracing::trace!("webhook already found for {} skipping...", repo);
}
(Some(webhook), true) => {
tracing::trace!("webhook already found for {} refreshing it", repo);
self.update_webhook(repo, webhook).await?;
}
(None, _) => {
tracing::trace!("webhook was not found for {} adding", repo);
self.add_webhook(repo).await?;
}
}
Ok(())
})
}
}
// <https://git.front.kjuulh.io/api/v1/user/repos?page=2>; rel="next",<https://git.front.kjuulh.io/api/v1/user/repos?page=9>; rel="last"
fn parse_link(page: usize, link_str: &str) -> anyhow::Result<Vec<usize>> {
let link_sections = link_str.split(',');
for link_section in link_sections {
if let Some((link, rel)) = link_section.rsplit_once("; ") {
if rel == r#"rel="last""# {
let actual_link = &link[1..link.len() - 1];
let url = Url::parse(actual_link)?;
if let Some(page_num) = url
.query_pairs()
.into_iter()
.find(|(name, _)| name == "page")
.map(|(_, value)| value)
{
let page_num: usize = page_num.parse()?;
let page_numbers = (page + 1..page_num).collect::<Vec<usize>>();
return Ok(page_numbers);
}
}
}
}
Ok(Vec::default())
}
mod extensions;
pub mod traits;
use anyhow::Context;
use backon::{ExponentialBuilder, Retryable};
pub use extensions::*;
use futures::{stream::FuturesUnordered, TryStreamExt};
use reqwest::{StatusCode, Url};
use serde::{Deserialize, Serialize};

View File

@ -1,11 +0,0 @@
use crate::SharedState;
use super::GiteaClient;
pub trait GiteaClientState {
fn gitea_client(&self) -> GiteaClient {
GiteaClient::new()
}
}
impl GiteaClientState for SharedState {}

View File

@ -1,28 +0,0 @@
use std::pin::Pin;
use futures::Future;
use super::Repository;
pub trait GiteaClient {
fn get_user_repositories<'a>(
&'a self,
user: &str,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<Repository>>> + Send + 'a>>;
fn get_org_repositories<'a>(
&'a self,
org: &'a str,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<Repository>>> + Send + 'a>>;
fn renovate_enabled<'a>(
&'a self,
repo: &'a Repository,
) -> Pin<Box<dyn Future<Output = anyhow::Result<bool>> + Send + 'a>>;
fn ensure_webhook<'a>(
&'a self,
repo: &'a Repository,
force_refresh: bool,
) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>>;
}

View File

@ -1,153 +0,0 @@
use anyhow::Context;
use futures::{stream::FuturesUnordered, StreamExt};
use itertools::Itertools;
use crate::SharedState;
use super::gitea::{GiteaClient, GiteaClientState, Repository};
pub struct Reconciler {
gitea_client: GiteaClient,
}
impl Reconciler {
pub fn new(gitea_client: GiteaClient) -> Self {
Self { gitea_client }
}
pub async fn reconcile(
&self,
user: Option<String>,
orgs: Option<Vec<String>>,
filter: Option<String>,
force_refresh: bool,
) -> anyhow::Result<()> {
let repos = self.get_repos(user, orgs).await?;
tracing::debug!("found repositories: {}", repos.len());
let filtered_repos = match filter {
Some(filter) => {
let re = regex::Regex::new(&filter).context(
"filter regex failed to compile, make sure it is valid against rust-lang/regex",
)?;
repos
.into_iter()
.filter(|r| {
if re.is_match(&r.to_string()) {
true
} else {
tracing::trace!(
filter = &filter,
"repository: {}, didn't match filter",
r.to_string(),
);
false
}
})
.collect()
}
None => repos,
};
tracing::debug!("filtered repositories: {}", filtered_repos.len());
let renovate_enabled = self.get_renovate_enabled(&filtered_repos).await?;
tracing::debug!(
"found repositories with renovate enabled: {}",
renovate_enabled.len()
);
self.ensure_webhook(&renovate_enabled, force_refresh)
.await?;
Ok(())
}
async fn get_repos(
&self,
user: Option<String>,
orgs: Option<Vec<String>>,
) -> anyhow::Result<Vec<Repository>> {
let mut repos = Vec::new();
if let Some(user) = user {
let mut r = self.gitea_client.get_user_repositories(&user).await?;
repos.append(&mut r);
}
if let Some(orgs) = orgs {
for org in orgs {
let mut r = self.gitea_client.get_org_repositories(&org).await?;
repos.append(&mut r);
}
}
Ok(repos.into_iter().unique().collect())
}
async fn get_renovate_enabled(&self, repos: &[Repository]) -> anyhow::Result<Vec<Repository>> {
let mut futures = FuturesUnordered::new();
for repo in repos {
futures.push(async move {
let enabled = self.gitea_client.renovate_enabled(repo).await?;
if enabled {
Ok::<Option<Repository>, anyhow::Error>(Some(repo.to_owned()))
} else {
tracing::trace!("repository: {:?}, doesn't have renovate enabled", repo);
Ok(None)
}
})
}
let mut enabled = Vec::new();
while let Some(res) = futures.next().await {
let res = res?;
if let Some(repo) = res {
enabled.push(repo)
}
}
Ok(enabled)
}
async fn ensure_webhook(
&self,
repos: &[Repository],
force_refresh: bool,
) -> anyhow::Result<()> {
tracing::debug!("ensuring webhooks are setup for repos");
let mut tasks = FuturesUnordered::new();
for repo in repos {
tasks.push(async move {
self.gitea_client
.ensure_webhook(repo, force_refresh)
.await?;
Ok::<(), anyhow::Error>(())
})
}
while let Some(res) = tasks.next().await {
res?;
}
Ok(())
}
}
pub trait ReconcilerState {
fn reconciler(&self) -> Reconciler;
}
impl ReconcilerState for SharedState {
fn reconciler(&self) -> Reconciler {
Reconciler::new(self.gitea_client())
}
}

View File

@ -1,3 +0,0 @@
pub struct RenovateConfig {
pub repo: String,
}

View File

@ -1,46 +0,0 @@
use std::{ops::Deref, sync::Arc};
use crate::services::engines::dagger::Dagger;
#[derive(Clone)]
pub struct SharedState(Arc<State>);
impl From<Arc<State>> for SharedState {
fn from(value: Arc<State>) -> Self {
Self(value)
}
}
impl Deref for SharedState {
type Target = Arc<State>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub struct State {
// pub db: Pool<Postgres>,
pub engine: Dagger,
}
impl State {
pub async fn new() -> anyhow::Result<Self> {
// let db = sqlx::PgPool::connect(
// &std::env::var("DATABASE_URL").context("DATABASE_URL is not set")?,
// )
// .await?;
// sqlx::migrate!("migrations/crdb")
// .set_locking(false)
// .run(&db)
// .await?;
// let _ = sqlx::query("SELECT 1;").fetch_one(&db).await?;
// Ok(Self { db })
let engine = Dagger::new();
Ok(Self { engine })
}
}

View File

@ -1,21 +1,27 @@
# yaml-language-server: $schema=https://git.front.kjuulh.io/kjuulh/cuddle/raw/branch/main/schemas/base.json
base: "git@git.front.kjuulh.io:kjuulh/cuddle-rust-service-plan.git"
base: "git@git.front.kjuulh.io:kjuulh/cuddle-golang-service-plan.git"
vars:
service: "contractor"
registry: kasperhermansen
docker_image: "docker:dind"
golang_builder_image: "golang:latest"
production_image: "alpine:latest"
clusters:
clank-prod:
replicas: "3"
namespace: prod
deployment:
registry: git@git.front.kjuulh.io:kjuulh/clank-clusters
env:
prod:
clusters:
- clank-prod
please:
project:
owner: kjuulh
repository: contractor
branch: main
settings:
api_url: https://git.front.kjuulh.io
scripts:
"ci:main":
type: shell
"ci:pr":
type: shell
"ci:release":
type: shell

64
go.mod Normal file
View File

@ -0,0 +1,64 @@
module git.front.kjuulh.io/kjuulh/contractor
go 1.21
toolchain go1.22.1
require (
dagger.io/dagger v0.8.1
github.com/bradleyfalzon/ghinstallation/v2 v2.10.0
github.com/gin-gonic/gin v1.9.1
github.com/google/go-github/v53 v53.2.0
github.com/google/go-github/v61 v61.0.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.8.0
)
require (
github.com/99designs/gqlgen v0.17.31 // indirect
github.com/Khan/genqlient v0.6.0 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
github.com/adrg/xdg v0.4.0 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-github/v55 v55.0.0 // indirect
github.com/google/go-github/v56 v56.0.0 // indirect
github.com/google/go-github/v57 v57.0.0 // indirect
github.com/google/go-github/v60 v60.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/vektah/gqlparser/v2 v2.5.6 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.12.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.12.0 // indirect
golang.org/x/oauth2 v0.8.0 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/text v0.12.0 // indirect
golang.org/x/tools v0.11.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

241
go.sum Normal file
View File

@ -0,0 +1,241 @@
cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
dagger.io/dagger v0.8.1 h1:jLNPGubxrLWUfsX+snjaw913B1lxVmWftzdVehB+RQU=
dagger.io/dagger v0.8.1/go.mod h1:CZwYt0FfVsEEYTFytzf2ihESB2P4H1S3/UfnrVxjBsE=
github.com/99designs/gqlgen v0.17.31 h1:VncSQ82VxieHkea8tz11p7h/zSbvHSxSDZfywqWt158=
github.com/99designs/gqlgen v0.17.31/go.mod h1:i4rEatMrzzu6RXaHydq1nmEPZkb3bKQsnxNRHS4DQB4=
github.com/Khan/genqlient v0.6.0 h1:Bwb1170ekuNIVIwTJEqvO8y7RxBxXu639VJOkKSrwAk=
github.com/Khan/genqlient v0.6.0/go.mod h1:rvChwWVTqXhiapdhLDV4bp9tz/Xvtewwkon4DpWWCRM=
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA=
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/bradleyfalzon/ghinstallation/v2 v2.6.0 h1:IRY7Xy588KylkoycsUhFpW7cdGpy5Y5BPsz4IfuJtGk=
github.com/bradleyfalzon/ghinstallation/v2 v2.6.0/go.mod h1:oQ3etOwN3TRH4EwgW5/7MxSVMGlMlzG/O8TU7eYdoSk=
github.com/bradleyfalzon/ghinstallation/v2 v2.7.0 h1:ranXaC3Zz/F6G/f0Joj3LrFp2OzOKfJZev5Q7OaMc88=
github.com/bradleyfalzon/ghinstallation/v2 v2.7.0/go.mod h1:ymxfmloxXBFXvvF1KpeUhOQM6Dfz9NYtfvTiJyk82UE=
github.com/bradleyfalzon/ghinstallation/v2 v2.8.0 h1:yUmoVv70H3J4UOqxqsee39+KlXxNEDfTbAp8c/qULKk=
github.com/bradleyfalzon/ghinstallation/v2 v2.8.0/go.mod h1:fmPmvCiBWhJla3zDv9ZTQSZc8AbwyRnGW1yg5ep1Pcs=
github.com/bradleyfalzon/ghinstallation/v2 v2.9.0 h1:HmxIYqnxubRYcYGRc5v3wUekmo5Wv2uX3gukmWJ0AFk=
github.com/bradleyfalzon/ghinstallation/v2 v2.9.0/go.mod h1:wmkTDJf8CmVypxE8ijIStFnKoTa6solK5QfdmJrP9KI=
github.com/bradleyfalzon/ghinstallation/v2 v2.10.0 h1:XWuWBRFEpqVrHepQob9yPS3Xg4K3Wr9QCx4fu8HbUNg=
github.com/bradleyfalzon/ghinstallation/v2 v2.10.0/go.mod h1:qoGA4DxWPaYTgVCrmEspVSjlTu4WYAiSxMIhorMRXXc=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v53 v53.2.0 h1:wvz3FyF53v4BK+AsnvCmeNhf8AkTaeh2SoYu/XUvTtI=
github.com/google/go-github/v53 v53.2.0/go.mod h1:XhFRObz+m/l+UCm9b7KSIC3lT3NWSXGt7mOsAWEloao=
github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg=
github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA=
github.com/google/go-github/v56 v56.0.0 h1:TysL7dMa/r7wsQi44BjqlwaHvwlFlqkK8CtBWCX3gb4=
github.com/google/go-github/v56 v56.0.0/go.mod h1:D8cdcX98YWJvi7TLo7zM4/h8ZTx6u6fwGEkCdisopo0=
github.com/google/go-github/v57 v57.0.0 h1:L+Y3UPTY8ALM8x+TV0lg+IEBI+upibemtBD8Q9u7zHs=
github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85PimQh4oygmLIxHw=
github.com/google/go-github/v58 v58.0.0/go.mod h1:k4hxDKEfoWpSqFlc8LTpGd9fu2KrV1YAa6Hi6FmDNY4=
github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8=
github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk=
github.com/google/go-github/v61 v61.0.0/go.mod h1:0WR+KmsWX75G2EbpyGsGmradjo3IiciuI4BmdVCobQY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vektah/gqlparser/v2 v2.5.6 h1:Ou14T0N1s191eRMZ1gARVqohcbe1e8FrcONScsq8cRU=
github.com/vektah/gqlparser/v2 v2.5.6/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8=
golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

76
internal/bot/giteabot.go Normal file
View File

@ -0,0 +1,76 @@
package bot
import (
"errors"
"fmt"
"strings"
"git.front.kjuulh.io/kjuulh/contractor/internal/models"
"git.front.kjuulh.io/kjuulh/contractor/internal/providers"
)
type BotHandler struct {
giteaClient *providers.GiteaClient
githubClient *providers.GitHubClient
}
func NewBotHandler(gitea *providers.GiteaClient, github *providers.GitHubClient) *BotHandler {
return &BotHandler{giteaClient: gitea, githubClient: github}
}
func (b *BotHandler) Handle(input string) (output string, err error) {
innerHandle := func(input string) (output string, err error) {
if strings.HasPrefix(input, "help") {
return b.Help(), nil
}
if strings.HasPrefix(input, "refresh") {
return `
<h3>Contractor triggered renovate refresh on this repository</h3>
This comment will be updated with status
<!-- Status update start -->
<!-- Status update end -->
`, nil
}
return b.Help(), errors.New("could not recognize command")
}
output, err = innerHandle(input)
output = fmt.Sprintf(
"%s\n<small>This comment was generated by <a href='https://git.front.kjuulh.io/kjuulh/contractor'>Contractor</a></small>",
output,
)
return output, err
}
func (b *BotHandler) Help() string {
return `<details open>
<summary><h3>/contractor [command]</h3></summary>
<strong>Commands:</strong>
* /contractor help
* triggers the help menu
* /contractor refresh
* triggers renovate to refresh the current pull request
</details>`
}
func (b *BotHandler) AppendComment(
owner string,
repository string,
pullRequest int,
comment string,
backend models.SupportedBackend,
) (*models.AddCommentResponse, error) {
switch backend {
case models.SupportedBackendGitHub:
return b.githubClient.AddComment(owner, repository, pullRequest, comment)
case models.SupportedBackendGitea:
return b.giteaClient.AddComment(owner, repository, pullRequest, comment)
default:
panic("backend chosen was not a valid option")
}
}

View File

@ -0,0 +1,84 @@
package features
import (
"context"
"encoding/json"
"log"
"time"
"git.front.kjuulh.io/kjuulh/contractor/internal/models"
"git.front.kjuulh.io/kjuulh/contractor/internal/providers"
"git.front.kjuulh.io/kjuulh/contractor/internal/queue"
"git.front.kjuulh.io/kjuulh/contractor/internal/renovate"
)
func RegisterGiteaQueues(goqueue *queue.GoQueue, renovate *renovate.RenovateClient, giteaClient *providers.GiteaClient) {
goqueue.Subscribe(
models.MessageTypeRefreshGiteaRepository,
func(ctx context.Context, item *queue.QueueMessage) error {
log.Printf("handling message: %s, content: %s", item.Type, item.Content)
return nil
},
)
goqueue.Subscribe(
models.MessageTypeRefreshGiteaRepositoryDone,
func(ctx context.Context, item *queue.QueueMessage) error {
log.Printf("handling message: %s, content: %s", item.Type, item.Content)
return nil
},
)
goqueue.Subscribe(
models.MessageTypeRefreshGiteaRepository,
func(ctx context.Context, item *queue.QueueMessage) error {
var request models.RefreshGiteaRepositoryRequest
if err := json.Unmarshal([]byte(item.Content), &request); err != nil {
log.Printf("failed to unmarshal request body: %s", err.Error())
return err
}
cancelCtx, cancel := context.WithTimeout(ctx, time.Minute*5)
defer cancel()
if err := renovate.RefreshRepository(cancelCtx, request.Owner, request.Repository); err != nil {
goqueue.Insert(models.MessageTypeRefreshGiteaRepositoryDone, models.RefreshGiteaRepositoryDoneRequest{
Repository: request.Repository,
Owner: request.Owner,
PullRequestID: request.PullRequestID,
CommentID: request.CommentID,
CommentBody: request.CommentBody,
ReportProgress: request.ReportProgress,
Status: "failed",
Error: err.Error(),
})
return err
}
goqueue.Insert(models.MessageTypeRefreshGiteaRepositoryDone, models.RefreshGiteaRepositoryDoneRequest{
Repository: request.Repository,
Owner: request.Owner,
PullRequestID: request.PullRequestID,
CommentID: request.CommentID,
CommentBody: request.CommentBody,
ReportProgress: request.ReportProgress,
Status: "done",
Error: "",
})
return nil
},
)
goqueue.Subscribe(
models.MessageTypeRefreshGiteaRepositoryDone,
func(ctx context.Context, item *queue.QueueMessage) error {
var doneRequest models.RefreshGiteaRepositoryDoneRequest
if err := json.Unmarshal([]byte(item.Content), &doneRequest); err != nil {
log.Printf("failed to unmarshal request body: %s", err.Error())
return err
}
return giteaClient.EditComment(ctx, &doneRequest)
},
)
}

View File

@ -0,0 +1,84 @@
package features
import (
"context"
"log"
"strings"
"git.front.kjuulh.io/kjuulh/contractor/internal/bot"
"git.front.kjuulh.io/kjuulh/contractor/internal/models"
"git.front.kjuulh.io/kjuulh/contractor/internal/queue"
)
type GiteaWebhook struct {
botHandler *bot.BotHandler
queue *queue.GoQueue
}
type GiteaWebhookRequest struct {
Action string `json:"action"`
Issue struct {
Id int `json:"id"`
Number int `json:"number"`
} `json:"issue"`
Comment struct {
Body string `json:"body"`
} `json:"comment"`
Repository struct {
FullName string `json:"full_name"`
}
}
func NewGiteaWebhook(botHandler *bot.BotHandler, queue *queue.GoQueue) *GiteaWebhook {
return &GiteaWebhook{
botHandler: botHandler,
queue: queue,
}
}
func (gw *GiteaWebhook) HandleGiteaWebhook(ctx context.Context, request *GiteaWebhookRequest) error {
command, ok := validateBotComment(request.Comment.Body)
if ok {
log.Printf("got webhook request: contractor %s", command)
bot := gw.botHandler
output, err := bot.Handle(command)
if err != nil {
log.Printf("failed to run bot handler with error: %s", err.Error())
}
parts := strings.Split(request.Repository.FullName, "/")
comment, err := bot.AppendComment(
parts[0],
parts[1],
request.Issue.Number,
output,
models.SupportedBackendGitea,
)
if err != nil {
return err
}
if err := gw.queue.Insert(models.MessageTypeRefreshGiteaRepository, models.RefreshGiteaRepositoryRequest{
Repository: parts[1],
Owner: parts[0],
PullRequestID: request.Issue.Number,
CommentID: comment.ID,
CommentBody: comment.Body,
ReportProgress: true,
}); err != nil {
return err
}
}
return nil
}
func validateBotComment(s string) (request string, ok bool) {
if after, ok := strings.CutPrefix(s, "/contractor"); ok {
return strings.TrimSpace(after), true
}
return "", false
}

View File

@ -0,0 +1,84 @@
package features
import (
"context"
"encoding/json"
"log"
"time"
"git.front.kjuulh.io/kjuulh/contractor/internal/models"
"git.front.kjuulh.io/kjuulh/contractor/internal/providers"
"git.front.kjuulh.io/kjuulh/contractor/internal/queue"
"git.front.kjuulh.io/kjuulh/contractor/internal/renovate"
)
func RegisterGitHubQueues(goqueue *queue.GoQueue, renovate *renovate.RenovateClient, giteaClient *providers.GitHubClient) {
goqueue.Subscribe(
models.MessageTypeRefreshGitHubRepository,
func(ctx context.Context, item *queue.QueueMessage) error {
log.Printf("handling message: %s, content: %s", item.Type, item.Content)
return nil
},
)
goqueue.Subscribe(
models.MessageTypeRefreshGitHubRepositoryDone,
func(ctx context.Context, item *queue.QueueMessage) error {
log.Printf("handling message: %s, content: %s", item.Type, item.Content)
return nil
},
)
goqueue.Subscribe(
models.MessageTypeRefreshGitHubRepository,
func(ctx context.Context, item *queue.QueueMessage) error {
var request models.RefreshGitHubRepositoryRequest
if err := json.Unmarshal([]byte(item.Content), &request); err != nil {
log.Printf("failed to unmarshal request body: %s", err.Error())
return err
}
cancelCtx, cancel := context.WithTimeout(ctx, time.Minute*5)
defer cancel()
if err := renovate.RefreshRepository(cancelCtx, request.Owner, request.Repository); err != nil {
goqueue.Insert(models.MessageTypeRefreshGitHubRepositoryDone, models.RefreshGitHubRepositoryDoneRequest{
Repository: request.Repository,
Owner: request.Owner,
PullRequestID: request.PullRequestID,
CommentID: request.CommentID,
CommentBody: request.CommentBody,
ReportProgress: request.ReportProgress,
Status: "failed",
Error: err.Error(),
})
return err
}
goqueue.Insert(models.MessageTypeRefreshGitHubRepositoryDone, models.RefreshGitHubRepositoryDoneRequest{
Repository: request.Repository,
Owner: request.Owner,
PullRequestID: request.PullRequestID,
CommentID: request.CommentID,
CommentBody: request.CommentBody,
ReportProgress: request.ReportProgress,
Status: "done",
Error: "",
})
return nil
},
)
goqueue.Subscribe(
models.MessageTypeRefreshGitHubRepositoryDone,
func(ctx context.Context, item *queue.QueueMessage) error {
var doneRequest models.RefreshGitHubRepositoryDoneRequest
if err := json.Unmarshal([]byte(item.Content), &doneRequest); err != nil {
log.Printf("failed to unmarshal request body: %s", err.Error())
return err
}
return giteaClient.EditComment(ctx, &doneRequest)
},
)
}

View File

@ -0,0 +1,76 @@
package features
import (
"context"
"log"
"strings"
"git.front.kjuulh.io/kjuulh/contractor/internal/bot"
"git.front.kjuulh.io/kjuulh/contractor/internal/models"
"git.front.kjuulh.io/kjuulh/contractor/internal/queue"
)
type GitHubWebhook struct {
botHandler *bot.BotHandler
queue *queue.GoQueue
}
type GitHubWebhookRequest struct {
Action string `json:"action"`
Issue struct {
Id int `json:"id"`
Number int `json:"number"`
} `json:"issue"`
Comment struct {
Body string `json:"body"`
} `json:"comment"`
Repository struct {
FullName string `json:"full_name"`
}
}
func NewGitHubWebhook(botHandler *bot.BotHandler, queue *queue.GoQueue) *GitHubWebhook {
return &GitHubWebhook{
botHandler: botHandler,
queue: queue,
}
}
func (gw *GitHubWebhook) HandleGitHubWebhook(ctx context.Context, request *GitHubWebhookRequest) error {
command, ok := validateBotComment(request.Comment.Body)
if ok {
log.Printf("got webhook request: contractor %s", command)
bot := gw.botHandler
output, err := bot.Handle(command)
if err != nil {
log.Printf("failed to run bot handler with error: %s", err.Error())
}
parts := strings.Split(request.Repository.FullName, "/")
comment, err := bot.AppendComment(
parts[0],
parts[1],
request.Issue.Number,
output,
models.SupportedBackendGitHub,
)
if err != nil {
return err
}
if err := gw.queue.Insert(models.MessageTypeRefreshGitHubRepository, models.RefreshGitHubRepositoryRequest{
Repository: parts[1],
Owner: parts[0],
PullRequestID: request.Issue.Number,
CommentID: comment.ID,
CommentBody: comment.Body,
ReportProgress: true,
}); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,69 @@
package models
const (
MessageTypeRefreshGiteaRepository = "refresh_gitea_repository"
MessageTypeRefreshGiteaRepositoryDone = "refresh_gitea_repository_done"
MessageTypeRefreshGitHubRepository = "refresh_github_repository"
MessageTypeRefreshGitHubRepositoryDone = "refresh_github_repository_done"
)
type CreateHook struct {
Active bool `json:"active"`
AuthorizationHeader string `json:"authorization_header"`
BranchFilter string `json:"branch_filter"`
Config map[string]string `json:"config"`
Events []string `json:"events"`
Type string `json:"type"`
}
type RefreshGiteaRepositoryRequest struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
PullRequestID int `json:"pullRequestId"`
CommentID int `json:"commentId"`
CommentBody string `json:"commentBody"`
ReportProgress bool `json:"reportProgress"`
}
type RefreshGiteaRepositoryDoneRequest struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
PullRequestID int `json:"pullRequestId"`
CommentID int `json:"commentId"`
CommentBody string `json:"commentBody"`
ReportProgress bool `json:"reportProgress"`
Status string `json:"status"`
Error string `json:"error"`
}
type RefreshGitHubRepositoryRequest struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
PullRequestID int `json:"pullRequestId"`
CommentID int `json:"commentId"`
CommentBody string `json:"commentBody"`
ReportProgress bool `json:"reportProgress"`
}
type RefreshGitHubRepositoryDoneRequest struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
PullRequestID int `json:"pullRequestId"`
CommentID int `json:"commentId"`
CommentBody string `json:"commentBody"`
ReportProgress bool `json:"reportProgress"`
Status string `json:"status"`
Error string `json:"error"`
}
type AddCommentResponse struct {
Body string `json:"body"`
ID int `json:"id"`
}
type SupportedBackend string
const (
SupportedBackendGitHub SupportedBackend = "github"
SupportedBackendGitea SupportedBackend = "gitea"
)

227
internal/providers/gitea.go Normal file
View File

@ -0,0 +1,227 @@
package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"io"
"log"
"net/http"
"strings"
"git.front.kjuulh.io/kjuulh/contractor/internal/models"
)
type GiteaClient struct {
url *string
token *string
client *http.Client
}
func NewGiteaClient(url, token *string) *GiteaClient {
return &GiteaClient{
url: url,
token: token,
client: http.DefaultClient,
}
}
func (gc *GiteaClient) EditComment(
ctx context.Context,
doneRequest *models.RefreshGiteaRepositoryDoneRequest,
) error {
commentBody := html.UnescapeString(doneRequest.CommentBody)
startCmnt := "<!-- Status update start -->"
startIdx := strings.Index(commentBody, startCmnt)
endIdx := strings.Index(commentBody, "<!-- Status update end -->")
if startIdx >= 0 && endIdx >= 0 {
log.Println("found comment to replace")
var content string
if doneRequest.Error != "" {
content = fmt.Sprintf("<pre>ERROR: %s</pre><br>", doneRequest.Error)
}
if doneRequest.Status != "" {
content = fmt.Sprintf("%s<p>%s</p>", content, doneRequest.Status)
}
doneRequest.CommentBody = fmt.Sprintf(
"%s<br><hr>%s<hr><br>%s",
commentBody[:startIdx+len(startCmnt)],
content,
commentBody[endIdx:],
)
}
editComment := struct {
Body string `json:"body"`
}{
Body: doneRequest.CommentBody,
}
body, err := json.Marshal(editComment)
if err != nil {
log.Println("failed to marshal request body: %w", err)
return err
}
bodyReader := bytes.NewReader(body)
request, err := http.NewRequest(
http.MethodPatch,
fmt.Sprintf(
"%s/repos/%s/%s/issues/comments/%d",
strings.TrimSuffix(*gc.url, "/"),
doneRequest.Owner,
doneRequest.Repository,
doneRequest.CommentID,
),
bodyReader,
)
if err != nil {
log.Printf("failed to form update comment request: %s", err.Error())
return err
}
request.Header.Add("Authorization", fmt.Sprintf("token %s", *gc.token))
request.Header.Add("Content-Type", "application/json")
resp, err := gc.client.Do(request)
if err != nil {
log.Printf("failed to update comment: %s", err.Error())
return err
}
if resp.StatusCode > 299 {
log.Printf("failed to update comment with status code: %d", resp.StatusCode)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("failed to read body of error response: %s", err.Error())
} else {
log.Printf("request body: %s", string(respBody))
}
}
return nil
}
func (gc *GiteaClient) CreateWebhook(owner, repository string) error {
createHookOptions := models.CreateHook{
Active: true,
AuthorizationHeader: "",
BranchFilter: "*",
Config: map[string]string{
"url": "http://10.0.9.1:8080/gitea/webhook",
"content_type": "json",
},
Events: []string{
"pull_request_comment",
},
Type: "gitea",
}
body, err := json.Marshal(createHookOptions)
if err != nil {
log.Println("failed to marshal request body: %w", err)
return err
}
bodyReader := bytes.NewReader(body)
request, err := http.NewRequest(
http.MethodPost,
fmt.Sprintf(
"%s/repos/%s/%s/hooks",
strings.TrimSuffix(*gc.url, "/"),
owner,
repository,
),
bodyReader,
)
if err != nil {
log.Printf("failed to form create hook request: %s", err.Error())
return err
}
request.Header.Add("Authorization", fmt.Sprintf("token %s", *gc.token))
request.Header.Add("Content-Type", "application/json")
resp, err := gc.client.Do(request)
if err != nil {
log.Printf("failed to register hook: %s", err.Error())
return err
}
if resp.StatusCode > 299 {
log.Printf("failed to register with status code: %d", resp.StatusCode)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("failed to read body of error response: %s", err.Error())
} else {
log.Printf("request body: %s", string(respBody))
}
}
return nil
}
func (gc *GiteaClient) AddComment(
owner, repository string,
pullRequest int,
comment string,
) (*models.AddCommentResponse, error) {
addComment := struct {
Body string `json:"body"`
}{
Body: comment,
}
body, err := json.Marshal(addComment)
if err != nil {
return nil, err
}
bodyReader := bytes.NewReader(body)
request, err := http.NewRequest(
http.MethodPost,
fmt.Sprintf(
"%s/repos/%s/%s/issues/%d/comments",
strings.TrimSuffix(*gc.url, "/"),
owner,
repository,
pullRequest,
),
bodyReader,
)
if err != nil {
return nil, err
}
request.Header.Add("Authorization", fmt.Sprintf("token %s", *gc.token))
request.Header.Add("Content-Type", "application/json")
resp, err := gc.client.Do(request)
if err != nil {
return nil, err
}
if resp.StatusCode > 299 {
log.Printf("failed to register with status code: %d", resp.StatusCode)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
} else {
log.Printf("request body: %s", string(respBody))
}
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var response models.AddCommentResponse
if err := json.Unmarshal(respBody, &response); err != nil {
return nil, err
}
return &response, nil
}

View File

@ -0,0 +1,117 @@
package providers
import (
"context"
"fmt"
"html"
"log"
"net/http"
"strings"
"git.front.kjuulh.io/kjuulh/contractor/internal/models"
"github.com/bradleyfalzon/ghinstallation/v2"
"github.com/google/go-github/v53/github"
)
type GitHubClient struct {
appID *int64
installationID *int64
privateKeyPath *string
client *github.Client
}
func NewGitHubClient(appID, installationID *int64, privateKeyPath *string) *GitHubClient {
return &GitHubClient{
appID: appID,
installationID: installationID,
privateKeyPath: privateKeyPath,
}
}
func (gc *GitHubClient) makeSureClientExist() {
if gc.client != nil {
return
}
tr := http.DefaultTransport
itr, err := ghinstallation.NewKeyFromFile(tr, *gc.appID, *gc.installationID, *gc.privateKeyPath)
if err != nil {
log.Fatal(err)
}
client := github.NewClient(&http.Client{Transport: itr})
gc.client = client
}
func (gc *GitHubClient) EditComment(
ctx context.Context,
doneRequest *models.RefreshGitHubRepositoryDoneRequest,
) error {
gc.makeSureClientExist()
commentBody := html.UnescapeString(doneRequest.CommentBody)
startCmnt := "<!-- Status update start -->"
startIdx := strings.Index(commentBody, startCmnt)
endIdx := strings.Index(commentBody, "<!-- Status update end -->")
if startIdx >= 0 && endIdx >= 0 {
log.Println("found comment to replace")
var content string
if doneRequest.Error != "" {
content = fmt.Sprintf("<pre>ERROR: %s</pre><br>", doneRequest.Error)
}
if doneRequest.Status != "" {
content = fmt.Sprintf("%s<p>%s</p>", content, doneRequest.Status)
}
doneRequest.CommentBody = fmt.Sprintf(
"%s<br><hr>%s<hr><br>%s",
commentBody[:startIdx+len(startCmnt)],
content,
commentBody[endIdx:],
)
}
_, _, err := gc.client.Issues.EditComment(ctx, doneRequest.Owner, doneRequest.Repository, int64(doneRequest.CommentID), &github.IssueComment{
Body: &doneRequest.CommentBody,
})
if err != nil {
log.Printf("failed to update comment: %s", err.Error())
return err
}
return nil
}
func (gc *GitHubClient) CreateWebhook(owner, repository string) error {
gc.makeSureClientExist()
// TODO: support for personal access tokens
// We implicitly get support via. github apps
return nil
}
func (gc *GitHubClient) AddComment(
owner, repository string,
pullRequest int,
comment string,
) (*models.AddCommentResponse, error) {
gc.makeSureClientExist()
resp, _, err := gc.client.Issues.CreateComment(context.Background(), owner, repository, pullRequest, &github.IssueComment{
Body: &comment,
})
if err != nil {
return nil, err
}
return &models.AddCommentResponse{
Body: *resp.Body,
ID: int(*resp.ID),
}, nil
}

113
internal/queue/goqueue.go Normal file
View File

@ -0,0 +1,113 @@
package queue
import (
"context"
"encoding/json"
"log"
"sync"
"github.com/google/uuid"
)
type QueueMessage struct {
Type string `json:"type"`
Content string `json:"content"`
}
type GoQueue struct {
queue []*QueueMessage
queueLock sync.Mutex
subscribers map[string]map[string]func(ctx context.Context, item *QueueMessage) error
subscribersLock sync.RWMutex
}
func NewGoQueue() *GoQueue {
return &GoQueue{
queue: make([]*QueueMessage, 0),
subscribers: make(
map[string]map[string]func(ctx context.Context, item *QueueMessage) error,
),
}
}
func (gq *GoQueue) Subscribe(
messageType string,
callback func(ctx context.Context, item *QueueMessage) error,
) string {
gq.subscribersLock.Lock()
defer gq.subscribersLock.Unlock()
uid, err := uuid.NewUUID()
if err != nil {
panic(err)
}
id := uid.String()
_, ok := gq.subscribers[messageType]
if !ok {
messageTypeSubscribers := make(
map[string]func(ctx context.Context, item *QueueMessage) error,
)
messageTypeSubscribers[id] = callback
gq.subscribers[messageType] = messageTypeSubscribers
} else {
gq.subscribers[messageType][id] = callback
}
return id
}
func (gq *GoQueue) Unsubscribe(messageType string, id string) {
gq.subscribersLock.Lock()
defer gq.subscribersLock.Unlock()
_, ok := gq.subscribers[messageType]
if !ok {
// No work to be done
return
} else {
delete(gq.subscribers[messageType], id)
}
}
func (gq *GoQueue) Insert(messageType string, content any) error {
gq.queueLock.Lock()
defer gq.queueLock.Unlock()
contents, err := json.Marshal(content)
if err != nil {
return err
}
gq.queue = append(gq.queue, &QueueMessage{
Type: messageType,
Content: string(contents),
})
go func() {
gq.handle(context.Background())
}()
return nil
}
func (gq *GoQueue) handle(ctx context.Context) {
gq.queueLock.Lock()
defer gq.queueLock.Unlock()
for {
if len(gq.queue) == 0 {
return
}
item := gq.queue[0]
gq.queue = gq.queue[1:]
gq.subscribersLock.RLock()
defer gq.subscribersLock.RUnlock()
for id, callback := range gq.subscribers[item.Type] {
log.Printf("sending message to %s", id)
go callback(ctx, item)
}
}
}

103
internal/renovate/client.go Normal file
View File

@ -0,0 +1,103 @@
package renovate
import (
"context"
"fmt"
"log"
"os"
"dagger.io/dagger"
)
type RenovateClient struct {
config string
}
func NewRenovateClient(config string) *RenovateClient {
return &RenovateClient{config: config}
}
func (rc *RenovateClient) RefreshRepository(ctx context.Context, owner, repository string) error {
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout))
if err != nil {
return err
}
envRenovateToken := os.Getenv("GITEA_RENOVATE_TOKEN")
log.Println(envRenovateToken)
renovateToken := client.SetSecret("RENOVATE_TOKEN", envRenovateToken)
githubComToken := client.SetSecret("GITHUB_COM_TOKEN", os.Getenv("GITHUB_COM_TOKEN"))
renovateSecret := client.SetSecret("RENOVATE_SECRETS", os.Getenv("RENOVATE_SECRETS"))
output, err := client.Container().
From("renovate/renovate:latest").
WithNewFile("/opts/renovate/config.json", dagger.ContainerWithNewFileOpts{
Contents: `{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"platform": "gitea",
"endpoint": "https://git.front.kjuulh.io/api/v1/",
"automerge": true,
"automergeType": "pr",
"extends": [
"config:base"
],
"hostRules": [
{
"hostType": "docker",
"matchHost": "harbor.front.kjuulh.io",
"username": "service",
"password": "{{ secrets.HARBOR_SERVER_PASSWORD }}"
}
],
"packageRules": [
{
"matchDatasources": ["docker"],
"registryUrls": ["https://harbor.front.kjuulh.io/docker-proxy/library/"]
},
{
"groupName": "all dependencies",
"separateMajorMinor": false,
"groupSlug": "all",
"packageRules": [
{
"matchPackagePatterns": [
"*"
],
"groupName": "all dependencies",
"groupSlug": "all"
}
],
"lockFileMaintenance": {
"enabled": false
}
}
]
}`,
Permissions: 755,
Owner: "root",
}).
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
WithSecretVariable("GITHUB_COM_TOKEN", githubComToken).
WithSecretVariable("RENOVATE_SECRETS", renovateSecret).
WithEnvVariable("LOG_LEVEL", "warn").
WithEnvVariable("RENOVATE_CONFIG_FILE", "/opts/renovate/config.json").
WithExec([]string{
fmt.Sprintf("%s/%s", owner, repository),
}).
Sync(ctx)
stdout, outerr := output.Stdout(ctx)
if outerr == nil {
log.Printf("stdout: %s", stdout)
}
stderr, outerr := output.Stderr(ctx)
if outerr == nil {
log.Printf("stderr: %s", stderr)
}
if err != nil {
return fmt.Errorf("error: %w, \nstderr: %s\nstdout: %s", err, stderr, stdout)
}
return nil
}

19
main.go Normal file
View File

@ -0,0 +1,19 @@
package main
import (
"log"
"git.front.kjuulh.io/kjuulh/contractor/cmd/contractor"
"github.com/joho/godotenv"
)
func main() {
err := godotenv.Load()
if err != nil {
log.Println("DEBUG: no .env file found")
}
if err := contractor.RootCmd().Execute(); err != nil {
log.Fatal(err)
}
}

23
scripts/ci:main.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -e
CMD_PREFIX=""
if [[ -n "$CI_PREFIX" ]]; then
CMD_PREFIX="$CI_PREFIX"
else
cd ci || return 1
cargo build
cd - || return 1
CMD_PREFIX="ci/target/debug/ci"
fi
$CMD_PREFIX main \
--docker-image "$DOCKER_IMAGE" \
--golang-builder-image "$GOLANG_BUILDER_IMAGE" \
--production-image "$PRODUCTION_IMAGE" \
--image "$REGISTRY/$SERVICE" \
--tag "main-$(date +%s)" \
--bin-name "$SERVICE"

25
scripts/ci:pr.sh Executable file
View File

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -e
CMD_PREFIX="cargo run -p ci --"
CMD_PREFIX=""
if [[ -n "$CI_PREFIX" ]]; then
CMD_PREFIX="$CI_PREFIX"
else
cd ci || return 1
cargo build
cd - || return 1
CMD_PREFIX="ci/target/debug/ci"
fi
$CMD_PREFIX pull-request \
--docker-image "$DOCKER_IMAGE" \
--golang-builder-image "$GOLANG_BUILDER_IMAGE" \
--production-image "$PRODUCTION_IMAGE" \
--image "$REGISTRY/$SERVICE" \
--tag "main-$(date +%s)" \
--bin-name "$SERVICE"

5
scripts/local:docker.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
cargo run -p ci -- local docker-image --image kasperhermansen/cuddle-please --tag dev --bin-name cuddle-please

5
scripts/local:docker:docs.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
cargo run -p ci -- local build-docs --mkdocs-image $MKDOCS_IMAGE --caddy-image $CADDY_IMAGE

5
scripts/mkdocs:build.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
docker run --rm -it -p 8000:8000 -v ${PWD}:/docs ${MKDOCS_IMAGE}

5
scripts/mkdocs:dev.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
docker run --rm -it -p 8000:8000 -v ${PWD}:/docs ${MKDOCS_IMAGE}

5
scripts/mkdocs:new.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
docker run --rm -it -v ${PWD}:/docs ${MKDOCS_IMAGE} new .

View File

@ -0,0 +1 @@

View File

@ -1,15 +0,0 @@
version: "3"
services:
crdb:
restart: 'always'
image: 'cockroachdb/cockroach:v23.1.14'
command: 'start-single-node --advertise-addr 0.0.0.0 --insecure'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"]
interval: '10s'
timeout: '30s'
retries: 5
start_period: '20s'
ports:
- 8080:8080
- '26257:26257'