├── src ├── api │ ├── models │ │ ├── mod.rs │ │ ├── templates │ │ │ └── model.rs.j2 │ │ ├── json │ │ │ └── gitlab.json │ │ ├── gitlab.rs │ │ └── gen_models.py │ ├── mod.rs │ ├── github_signature.rs │ ├── github_proto.rs │ ├── github_client.rs │ └── gitlab_client.rs ├── actions │ └── actions.rs ├── testing.rs ├── main.rs ├── service.rs ├── config.rs ├── errors.rs ├── commands.rs ├── testdata │ ├── github_created_issue_comment.json │ ├── github_get_pr.json │ ├── github_open_pr_forked.json │ ├── github_open_pull_request.json │ └── github_reopen_pull_request.json └── github.rs ├── .dockerignore ├── .github ├── FUNDING.yml └── dependabot.yml ├── docs ├── github-webhook-config.png ├── github-personal-access-token.png └── gitlab-personal-access-token.png ├── .gitignore ├── helm └── labhub │ ├── Chart.yaml │ ├── templates │ ├── configmap.yaml │ ├── tests │ │ └── test-connection.yaml │ ├── service.yaml │ ├── _helpers.tpl │ ├── ingress.yaml │ ├── NOTES.txt │ └── deployment.yaml │ ├── .helmignore │ └── values.yaml ├── Dockerfile ├── Rocket.toml ├── Cargo.toml ├── LICENSE ├── LabHub.toml ├── Dockerfile.builder ├── README.md └── .gitlab-ci.yml /src/api/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod github; 2 | pub mod gitlab; 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | 4 | .env 5 | 6 | # Ignore IDE files 7 | .vscode/ 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: brndnmtthws 4 | -------------------------------------------------------------------------------- /docs/github-webhook-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brndnmtthws/labhub/HEAD/docs/github-webhook-config.png -------------------------------------------------------------------------------- /docs/github-personal-access-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brndnmtthws/labhub/HEAD/docs/github-personal-access-token.png -------------------------------------------------------------------------------- /docs/gitlab-personal-access-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brndnmtthws/labhub/HEAD/docs/gitlab-personal-access-token.png -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod github_client; 2 | pub mod github_proto; 3 | pub mod github_signature; 4 | pub mod gitlab_client; 5 | pub mod models; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | 4 | .env 5 | 6 | # Ignore IDE files 7 | .vscode/ 8 | 9 | # Ignore my helm values. 10 | myvalues.yaml 11 | -------------------------------------------------------------------------------- /helm/labhub/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | appVersion: "0.1.0" 3 | description: A Helm chart for LabHub (https://github.com/brndnmtthws/labhub) 4 | name: labhub 5 | version: 0.1.0 6 | -------------------------------------------------------------------------------- /src/api/models/templates/model.rs.j2: -------------------------------------------------------------------------------- 1 | // This file is auto-generated, do not edit. 2 | {% for key, value in structs.items() %} 3 | #[derive(Serialize, Deserialize, Debug)] 4 | pub struct {{ key }} { 5 | {%- for field in value %} 6 | {{ field }} 7 | {%- endfor %} 8 | } 9 | {% endfor %} 10 | -------------------------------------------------------------------------------- /src/api/models/json/gitlab.json: -------------------------------------------------------------------------------- 1 | { 2 | "pipeline": { 3 | "id": 47, 4 | "status": "pending", 5 | "ref": "new-pipeline", 6 | "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", 7 | "web_url": "https://example.com/foo/bar/pipelines/47" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/api/models/gitlab.rs: -------------------------------------------------------------------------------- 1 | // This file is auto-generated, do not edit. 2 | 3 | #[derive(Serialize, Deserialize, Debug)] 4 | pub struct Pipeline { 5 | pub id: Option, 6 | pub status: Option, 7 | #[serde(rename = "ref")] 8 | pub ref_key: Option, 9 | pub sha: Option, 10 | pub web_url: Option, 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rustlang/rust:nightly 2 | 3 | COPY . /labhub/src 4 | WORKDIR /labhub 5 | RUN cd src \ 6 | && cargo install --path . \ 7 | && cd .. \ 8 | && cp src/Rocket.toml . \ 9 | && rm -rf src \ 10 | && rm -rf $HOME/.cargo/registry \ 11 | && rm -rf $HOME/.cargo/git 12 | 13 | ENV ROCKET_ENV=production 14 | ENV RUST_LOG=labhub=info 15 | 16 | ENTRYPOINT [ "labhub" ] 17 | -------------------------------------------------------------------------------- /helm/labhub/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ template "labhub.fullname" . }} 5 | labels: 6 | app.kubernetes.io/managed-by: {{ .Release.Service }} 7 | app.kubernetes.io/instance: {{ .Release.Name }} 8 | helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} 9 | app.kubernetes.io/name: {{ template "labhub.name" . }} 10 | data: 11 | LabHub.toml: {{ .Values.labhub_toml | quote }} 12 | -------------------------------------------------------------------------------- /helm/labhub/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /src/actions/actions.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, PartialEq, Deserialize)] 2 | #[serde(rename_all = "camelCase")] 3 | pub enum ActionValue { 4 | Assigned, 5 | Closed, 6 | Edited, 7 | Labeled, 8 | Locked, 9 | Opened, 10 | ReadyForReview, 11 | Reopened, 12 | ReviewRequested, 13 | ReviewRequestRemoved, 14 | Synchronize, 15 | Unassigned, 16 | Unlabeled, 17 | Unlocked, 18 | } 19 | 20 | #[derive(Debug, PartialEq)] 21 | pub struct Action { 22 | pub action: ActionValue, 23 | } 24 | -------------------------------------------------------------------------------- /Rocket.toml: -------------------------------------------------------------------------------- 1 | # Default settings for Rocket. 2 | # For details on Rocket configuration, see: 3 | # https://rocket.rs/v0.4/guide/configuration/#configuratio 4 | [development] 5 | address = "localhost" 6 | port = 8000 7 | keep_alive = 5 8 | log = "normal" 9 | limits = { forms = 32768 } 10 | 11 | [staging] 12 | address = "0.0.0.0" 13 | port = 8000 14 | keep_alive = 5 15 | log = "normal" 16 | limits = { forms = 32768 } 17 | 18 | [production] 19 | address = "0.0.0.0" 20 | port = 8000 21 | keep_alive = 5 22 | log = "critical" 23 | limits = { forms = 32768 } 24 | -------------------------------------------------------------------------------- /helm/labhub/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "labhub.fullname" . }}-test-connection" 5 | labels: 6 | app.kubernetes.io/name: {{ include "labhub.name" . }} 7 | helm.sh/chart: {{ include "labhub.chart" . }} 8 | app.kubernetes.io/instance: {{ .Release.Name }} 9 | app.kubernetes.io/managed-by: {{ .Release.Service }} 10 | annotations: 11 | "helm.sh/hook": test-success 12 | spec: 13 | containers: 14 | - name: wget 15 | image: busybox 16 | command: ['wget'] 17 | args: ['{{ include "labhub.fullname" . }}:{{ .Values.service.port }}'] 18 | restartPolicy: Never 19 | -------------------------------------------------------------------------------- /helm/labhub/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "labhub.fullname" . }} 5 | labels: 6 | app.kubernetes.io/name: {{ include "labhub.name" . }} 7 | helm.sh/chart: {{ include "labhub.chart" . }} 8 | app.kubernetes.io/instance: {{ .Release.Name }} 9 | app.kubernetes.io/managed-by: {{ .Release.Service }} 10 | spec: 11 | type: {{ .Values.service.type }} 12 | ports: 13 | - port: {{ .Values.service.port }} 14 | targetPort: http 15 | protocol: TCP 16 | name: http 17 | selector: 18 | app.kubernetes.io/name: {{ include "labhub.name" . }} 19 | app.kubernetes.io/instance: {{ .Release.Name }} 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | reviewers: 10 | - brndnmtthws 11 | ignore: 12 | - dependency-name: reqwest 13 | versions: 14 | - 0.11.0 15 | - 0.11.1 16 | - 0.11.2 17 | - dependency-name: serde_derive 18 | versions: 19 | - 1.0.123 20 | - 1.0.124 21 | - dependency-name: serde 22 | versions: 23 | - 1.0.123 24 | - 1.0.124 25 | - dependency-name: serde_json 26 | versions: 27 | - 1.0.61 28 | - 1.0.62 29 | - 1.0.63 30 | - dependency-name: futures 31 | versions: 32 | - 0.3.12 33 | - 0.3.13 34 | - dependency-name: git2 35 | versions: 36 | - 0.13.17 37 | - dependency-name: env_logger 38 | versions: 39 | - 0.8.2 40 | - dependency-name: rocket_contrib 41 | versions: 42 | - 0.4.6 43 | - dependency-name: rocket 44 | versions: 45 | - 0.4.6 46 | -------------------------------------------------------------------------------- /src/testing.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::prelude::*; 3 | use std::panic; 4 | use std::path::PathBuf; 5 | use std::sync::atomic::{AtomicBool, Ordering}; 6 | 7 | pub fn read_testdata_to_string(filename: &str) -> String { 8 | let mut datapath = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 9 | datapath.push("src/testdata"); 10 | datapath.push(filename); 11 | 12 | let mut file = File::open(datapath).expect("Unable to open the file"); 13 | let mut contents = String::new(); 14 | file.read_to_string(&mut contents) 15 | .expect("Unable to read the file"); 16 | contents 17 | } 18 | 19 | static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false); 20 | 21 | fn setup() { 22 | if !LOGGER_INITIALIZED.compare_and_swap(false, true, Ordering::Relaxed) { 23 | env_logger::try_init().expect("Error initializing logger"); 24 | } 25 | } 26 | 27 | pub fn run_test(test: T) 28 | where 29 | T: FnOnce() -> () + panic::UnwindSafe, 30 | { 31 | setup(); 32 | 33 | let result = panic::catch_unwind(test); 34 | 35 | assert!(result.is_ok()) 36 | } 37 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(proc_macro_hygiene, decl_macro, try_trait)] 2 | 3 | #[macro_use] 4 | extern crate rocket; 5 | #[macro_use] 6 | extern crate rocket_contrib; 7 | #[macro_use] 8 | extern crate serde_derive; 9 | #[macro_use] 10 | extern crate lazy_static; 11 | extern crate futures; 12 | extern crate regex; 13 | extern crate reqwest; 14 | extern crate toml; 15 | extern crate url; 16 | 17 | mod api; 18 | mod commands; 19 | mod config; 20 | mod errors; 21 | mod github; 22 | mod service; 23 | 24 | #[cfg(test)] 25 | mod testing; 26 | 27 | use log::info; 28 | 29 | fn main() { 30 | let rocket = rocket::ignite(); 31 | 32 | info!("✨ May your hopes and dreams become reality ✨"); 33 | config::load_config(); 34 | 35 | rocket 36 | .mount("/github", routes![service::github_event]) 37 | .mount("/gitlab", routes![service::gitlab_event]) 38 | .mount("/", routes![service::check]) 39 | .register(catchers![ 40 | errors::not_found, 41 | errors::internal_server_error, 42 | errors::unprocessable_entity 43 | ]) 44 | .launch(); 45 | } 46 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Brenden Matthews "] 3 | categories = ["development-tools", "development-tools::build-utils"] 4 | description = "Bot for running builds against GitHub with GitLab CI" 5 | edition = "2018" 6 | keywords = ["github", "gitlab", "ci", "restful", "rest-api"] 7 | license = "Unlicense" 8 | name = "labhub" 9 | readme = "README.md" 10 | repository = "https://github.com/brndnmtthws/labhub" 11 | version = "0.1.11" 12 | 13 | [badges] 14 | gitlab = { repository = "brndnmtthws-oss/labhub", branch = "master" } 15 | 16 | [dependencies] 17 | futures = "0.3" 18 | git2 = "0.13" 19 | hex = "0.4" 20 | lazy_static = "1.3" 21 | log = "0.4" 22 | percent-encoding = "2.1" 23 | regex = "1" 24 | reqwest = "0.10" 25 | ring = "0.13" 26 | rocket = "0.4" 27 | serde = "1.0" 28 | serde_derive = "1.0" 29 | serde_json = "1.0" 30 | tempfile = "3.1" 31 | toml = "0.5" 32 | url = "2.2" 33 | yansi = "0.5" 34 | 35 | [dependencies.rocket_contrib] 36 | default-features = false 37 | features = ["json"] 38 | version = "0.4" 39 | 40 | [dev-dependencies] 41 | mockers = "0.21" 42 | mockers_derive = "0.21" 43 | env_logger = { version = "0.9", default-features = false } 44 | -------------------------------------------------------------------------------- /helm/labhub/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "labhub.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "labhub.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "labhub.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | -------------------------------------------------------------------------------- /helm/labhub/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "labhub.fullname" . -}} 3 | {{- $ingressPaths := .Values.ingress.paths -}} 4 | apiVersion: extensions/v1beta1 5 | kind: Ingress 6 | metadata: 7 | name: {{ $fullName }} 8 | labels: 9 | app.kubernetes.io/name: {{ include "labhub.name" . }} 10 | helm.sh/chart: {{ include "labhub.chart" . }} 11 | app.kubernetes.io/instance: {{ .Release.Name }} 12 | app.kubernetes.io/managed-by: {{ .Release.Service }} 13 | {{- with .Values.ingress.annotations }} 14 | annotations: 15 | {{- toYaml . | nindent 4 }} 16 | {{- end }} 17 | spec: 18 | {{- if .Values.ingress.tls }} 19 | tls: 20 | {{- range .Values.ingress.tls }} 21 | - hosts: 22 | {{- range .hosts }} 23 | - {{ . | quote }} 24 | {{- end }} 25 | secretName: {{ .secretName }} 26 | {{- end }} 27 | {{- end }} 28 | rules: 29 | {{- range .Values.ingress.hosts }} 30 | - host: {{ . | quote }} 31 | http: 32 | paths: 33 | {{- range $ingressPaths }} 34 | - path: {{ . }} 35 | backend: 36 | serviceName: {{ $fullName }} 37 | servicePort: http 38 | {{- end }} 39 | {{- end }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /src/api/github_signature.rs: -------------------------------------------------------------------------------- 1 | use hex; 2 | use log::{debug, warn}; 3 | use ring::{digest, hmac}; 4 | 5 | impl From for SignatureError { 6 | fn from(error: ring::error::Unspecified) -> Self { 7 | warn!("Got a bad request signature: {:?}", error); 8 | SignatureError::BadSignature 9 | } 10 | } 11 | 12 | impl From for SignatureError { 13 | fn from(error: hex::FromHexError) -> Self { 14 | warn!("Error decoding hex signature: {:?}", error); 15 | SignatureError::InvalidEncoding 16 | } 17 | } 18 | 19 | #[derive(Debug)] 20 | pub enum SignatureError { 21 | BadSignature, 22 | InvalidFormat, 23 | InvalidEncoding, 24 | } 25 | 26 | pub fn check_signature(secret: &str, signature: &str, body: &str) -> Result<(), SignatureError> { 27 | let v_key = hmac::VerificationKey::new(&digest::SHA1, secret.as_bytes()); 28 | let signature_parts = signature.split('=').collect::>(); 29 | match signature_parts.len() { 30 | 2 => { 31 | hmac::verify(&v_key, body.as_bytes(), &hex::decode(signature_parts[1])?)?; 32 | debug!("Good signature {} for GitHub", signature); 33 | Ok(()) 34 | } 35 | _ => Err(SignatureError::InvalidFormat), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /LabHub.toml: -------------------------------------------------------------------------------- 1 | # List of enabled features 2 | features = [ 3 | "external_pr", 4 | "commands" 5 | ] 6 | 7 | # Command settings 8 | [commands] 9 | # List of commands to enable 10 | enabled_commands = [ 11 | "retry", 12 | ] 13 | 14 | # Settings for GitHub 15 | [github] 16 | webhook_secret = "secret" 17 | username = "ci-user" 18 | ssh_key = "/etc/ssh-keys/labhub-key.ecdsa" 19 | api_token = "token" 20 | hostname = "github.com" 21 | 22 | # Settings for GitLab 23 | [gitlab] 24 | webhook_secret = "secret" 25 | username = "ci-user" 26 | ssh_key = "/etc/ssh-keys/labhub-key.ecdsa" 27 | api_token = "token" 28 | hostname = "gitlab.com" 29 | 30 | # List of mappings to/from GitHub & GitLab 31 | [[mappings]] 32 | github_repo = "brndnmtthws/labhub" 33 | gitlab_repo = "brndnmtthws-oss/labhub" 34 | [[mappings]] 35 | github_repo = "brndnmtthws/conky" 36 | gitlab_repo = "brndnmtthws-oss/conky" 37 | 38 | # pull request event trigger actions 39 | [actions] 40 | # list of enabled actions 41 | enabled_actions = [ 42 | "assigned", 43 | "closed", 44 | "edited", 45 | "labeled", 46 | "locked", 47 | "opened", 48 | "ready_for_review", 49 | "reopened", 50 | "review_requested", 51 | "review_request_removed", 52 | "synchronize", 53 | "unassigned", 54 | "unlabeled", 55 | "unlocked", 56 | ] 57 | -------------------------------------------------------------------------------- /src/api/github_proto.rs: -------------------------------------------------------------------------------- 1 | use rocket::http::Status; 2 | use rocket::request::{self, FromRequest, Request}; 3 | use rocket::Outcome; 4 | 5 | #[derive(Debug)] 6 | pub enum RequestError { 7 | BadCount, 8 | Missing, 9 | } 10 | 11 | pub struct XGitHubEvent(pub String); 12 | 13 | impl<'a, 'r> FromRequest<'a, 'r> for XGitHubEvent { 14 | type Error = RequestError; 15 | 16 | fn from_request(request: &'a Request<'r>) -> request::Outcome { 17 | let events: Vec<_> = request.headers().get("X-GitHub-Event").collect(); 18 | 19 | match events.len() { 20 | 0 => Outcome::Failure((Status::BadRequest, RequestError::Missing)), 21 | 1 => Outcome::Success(XGitHubEvent(events[0].to_string())), 22 | _ => Outcome::Failure((Status::BadRequest, RequestError::BadCount)), 23 | } 24 | } 25 | } 26 | 27 | pub struct XHubSignature(pub String); 28 | 29 | impl<'a, 'r> FromRequest<'a, 'r> for XHubSignature { 30 | type Error = RequestError; 31 | 32 | fn from_request(request: &'a Request<'r>) -> request::Outcome { 33 | let signatures: Vec<_> = request.headers().get("X-Hub-Signature").collect(); 34 | 35 | match signatures.len() { 36 | 0 => Outcome::Failure((Status::BadRequest, RequestError::Missing)), 37 | 1 => Outcome::Success(XHubSignature(signatures[0].to_string())), 38 | _ => Outcome::Failure((Status::BadRequest, RequestError::BadCount)), 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /helm/labhub/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range $.Values.ingress.paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host }}{{ . }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "labhub.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get svc -w {{ include "labhub.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "labhub.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "labhub.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | echo "Visit http://127.0.0.1:8080 to use your application" 20 | kubectl port-forward $POD_NAME 8080:80 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /Dockerfile.builder: -------------------------------------------------------------------------------- 1 | FROM rustlang/rust:nightly 2 | 3 | WORKDIR /labhub 4 | 5 | ENV SCCACHE_VER=0.2.10 6 | ENV GITHUB_RELEASE_VER=v0.7.2 7 | 8 | RUN wget -q https://github.com/mozilla/sccache/releases/download/${SCCACHE_VER}/sccache-${SCCACHE_VER}-x86_64-unknown-linux-musl.tar.gz -O sccache-${SCCACHE_VER}-x86_64-unknown-linux-musl.tar.gz \ 9 | && tar xf sccache-${SCCACHE_VER}-x86_64-unknown-linux-musl.tar.gz \ 10 | && cp sccache-${SCCACHE_VER}-x86_64-unknown-linux-musl/sccache /usr/bin \ 11 | && apt-get update \ 12 | && apt-get install -y cmake curl \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | RUN rustup component add clippy \ 16 | && rustup component add rustfmt \ 17 | && rustup target add i686-apple-darwin \ 18 | && rustup target add i686-pc-windows-gnu \ 19 | && rustup target add i686-pc-windows-msvc \ 20 | && rustup target add i686-unknown-linux-gnu \ 21 | && rustup target add x86_64-apple-darwin \ 22 | && rustup target add x86_64-pc-windows-gnu \ 23 | && rustup target add x86_64-pc-windows-msvc \ 24 | && rustup target add x86_64-unknown-freebsd \ 25 | && rustup target add x86_64-unknown-linux-gnu \ 26 | && RUSTFLAGS="--cfg procmacro2_semver_exempt" cargo install cargo-tarpaulin \ 27 | && curl -Lq https://github.com/aktau/github-release/releases/download/${GITHUB_RELEASE_VER}/linux-amd64-github-release.tar.bz2 -o linux-amd64-github-release.tar.bz2 \ 28 | && tar xvf linux-amd64-github-release.tar.bz2 \ 29 | && mv bin/linux/amd64/github-release /usr/bin \ 30 | && rm -rf $CARGO_HOME/registry \ 31 | && rm -rf $CARGO_HOME/git 32 | 33 | ENV RUSTC_WRAPPER=sccache 34 | -------------------------------------------------------------------------------- /src/service.rs: -------------------------------------------------------------------------------- 1 | use crate::api::{github_proto, github_signature}; 2 | use crate::config; 3 | use crate::errors; 4 | use crate::github; 5 | 6 | use log::{debug, info}; 7 | use rocket::data::Data; 8 | use rocket::response::content; 9 | use rocket_contrib::json::Json; 10 | use std::io::Read; 11 | 12 | const MAX_BODY_LENGTH: u64 = 10 * 1024 * 1024; 13 | 14 | #[get("/check")] 15 | pub fn check() -> &'static str { 16 | "ok" 17 | } 18 | 19 | #[post("/events", format = "json", data = "")] 20 | pub fn github_event( 21 | body_data: Data, 22 | event_type: github_proto::XGitHubEvent, 23 | signature: github_proto::XHubSignature, 24 | ) -> Result, errors::RequestErrorResult> { 25 | info!("Received GitHub webhook, type={}", event_type.0); 26 | 27 | // Read request body 28 | let mut body = String::with_capacity(MAX_BODY_LENGTH as usize); 29 | body_data 30 | .open() 31 | .take(MAX_BODY_LENGTH) 32 | .read_to_string(&mut body)?; 33 | 34 | // Check X-Hub-Signature 35 | github_signature::check_signature( 36 | &config::CONFIG.github.webhook_secret.clone(), 37 | &signature.0, 38 | &body, 39 | )?; 40 | 41 | debug!("body={}", body); 42 | 43 | // Handle the event 44 | Ok(content::Json(github::handle_event_body( 45 | &event_type.0.as_ref(), 46 | &body, 47 | )?)) 48 | } 49 | 50 | #[post("/events", format = "json", data = "")] 51 | pub fn gitlab_event( 52 | event: Json, 53 | ) -> Result, errors::RequestErrorResult> { 54 | info!("{:?}", event.0); 55 | Ok(content::Json(json!({"hello":"hi"}).to_string())) 56 | } 57 | -------------------------------------------------------------------------------- /helm/labhub/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for labhub. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: brndnmtthws/labhub 9 | tag: latest 10 | pullPolicy: IfNotPresent 11 | 12 | nameOverride: "" 13 | fullnameOverride: "" 14 | 15 | service: 16 | type: ClusterIP 17 | port: 80 18 | 19 | ingress: 20 | enabled: false 21 | annotations: 22 | {} 23 | # kubernetes.io/ingress.class: nginx 24 | # kubernetes.io/tls-acme: "true" 25 | paths: ["/"] 26 | hosts: 27 | - labhub.local 28 | tls: [] 29 | # - secretName: chart-example-tls 30 | # hosts: 31 | # - labhub.local 32 | 33 | resources: 34 | # We usually recommend not to specify default resources and to leave this as a conscious 35 | # choice for the user. This also increases chances charts run on environments with little 36 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 37 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 38 | limits: 39 | cpu: 100m 40 | memory: 128Mi 41 | requests: 42 | cpu: 100m 43 | memory: 128Mi 44 | 45 | nodeSelector: {} 46 | 47 | tolerations: [] 48 | 49 | affinity: {} 50 | 51 | envVars: {} 52 | 53 | labhub_toml: | 54 | # List of enabled features. At the moment there's only one, so this parameter 55 | # does nothing. :) 56 | features = [ 57 | "external_pr", 58 | "commands" 59 | ] 60 | 61 | [commands] 62 | enabled_commands = [ 63 | "retry", 64 | ] 65 | 66 | # Settings for GitHub 67 | [github] 68 | webhook_secret = "secret" 69 | username = "ci-user" 70 | ssh_key = "/etc/ssh-keys/labhub-key.ecdsa" 71 | api_token = "token" 72 | hostname = "github.com" 73 | 74 | # Settings for GitLab 75 | [gitlab] 76 | webhook_secret = "secret" 77 | username = "ci-user" 78 | ssh_key = "/etc/ssh-keys/labhub-key.ecdsa" 79 | api_token = "token" 80 | hostname = "gitlab.com" 81 | 82 | # List of mappings to/from GitHub & GitLab 83 | [[mappings]] 84 | github_repo = "brndnmtthws/labhub" 85 | gitlab_repo = "brndnmtthws-oss/labhub" 86 | [[mappings]] 87 | github_repo = "brndnmtthws/conky" 88 | gitlab_repo = "brndnmtthws-oss/conky" 89 | -------------------------------------------------------------------------------- /src/api/github_client.rs: -------------------------------------------------------------------------------- 1 | use crate::api::models::github; 2 | use crate::config; 3 | use crate::errors::GitError; 4 | 5 | use log::error; 6 | use reqwest; 7 | 8 | fn headers(token: &str) -> reqwest::header::HeaderMap { 9 | let mut headers = reqwest::header::HeaderMap::new(); 10 | headers.insert( 11 | reqwest::header::AUTHORIZATION, 12 | reqwest::header::HeaderValue::from_str(&format!("token {}", token)).unwrap(), 13 | ); 14 | headers.insert( 15 | reqwest::header::ACCEPT, 16 | reqwest::header::HeaderValue::from_static("application/vnd.github.v3+json"), 17 | ); 18 | headers.insert( 19 | reqwest::header::ACCEPT_ENCODING, 20 | reqwest::header::HeaderValue::from_static("Accept-Encoding: deflate, gzip"), 21 | ); 22 | headers 23 | } 24 | 25 | fn make_repo_url(org: &str, repo: &str) -> String { 26 | let hostname = match config::CONFIG.github.hostname.as_ref() { 27 | Some(hostname) => hostname.clone(), 28 | _ => "github.com".to_string(), 29 | }; 30 | format!("https://api.{}/repos/{}/{}", hostname, org, repo) 31 | } 32 | 33 | pub fn get_pull( 34 | client: &reqwest::Client, 35 | org: &str, 36 | repo: &str, 37 | number: i64, 38 | ) -> Result { 39 | let res: github::RepoPr = client 40 | .get(&format!("{}/pulls/{}", make_repo_url(org, repo), number)) 41 | .headers(headers(&config::CONFIG.github.api_token)) 42 | .send()? 43 | .json()?; 44 | Ok(res) 45 | } 46 | 47 | pub fn create_issue_comment( 48 | client: &reqwest::Client, 49 | org: &str, 50 | repo: &str, 51 | number: i64, 52 | body: &str, 53 | ) -> Result<(), GitError> { 54 | let mut res = client 55 | .post(&format!( 56 | "{}/issues/{}/comments", 57 | make_repo_url(org, repo), 58 | number 59 | )) 60 | .headers(headers(&config::CONFIG.github.api_token)) 61 | .body(serde_json::json!({"body":body.to_string()}).to_string()) 62 | .send()?; 63 | let body = res.text()?; 64 | match res.status() { 65 | reqwest::StatusCode::CREATED => Ok(()), 66 | _ => { 67 | let msg = format!("Error creating issue comment: res={:#?} body={}", res, body); 68 | error!("{}", msg); 69 | Err(GitError { message: msg }) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /helm/labhub/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "labhub.fullname" . }} 5 | labels: 6 | app.kubernetes.io/name: {{ include "labhub.name" . }} 7 | helm.sh/chart: {{ include "labhub.chart" . }} 8 | app.kubernetes.io/instance: {{ .Release.Name }} 9 | app.kubernetes.io/managed-by: {{ .Release.Service }} 10 | spec: 11 | replicas: {{ .Values.replicaCount }} 12 | selector: 13 | matchLabels: 14 | app.kubernetes.io/name: {{ include "labhub.name" . }} 15 | app.kubernetes.io/instance: {{ .Release.Name }} 16 | template: 17 | metadata: 18 | labels: 19 | app.kubernetes.io/name: {{ include "labhub.name" . }} 20 | app.kubernetes.io/instance: {{ .Release.Name }} 21 | spec: 22 | containers: 23 | - name: {{ .Chart.Name }} 24 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 25 | imagePullPolicy: {{ .Values.image.pullPolicy }} 26 | env: 27 | - name: LABHUB_TOML 28 | value: "/etc/labhub/LabHub.toml" 29 | {{- with .Values.envVars }} 30 | {{- toYaml . | nindent 12 }} 31 | {{- end }} 32 | volumeMounts: 33 | - name: labhub-ssh-keys 34 | mountPath: "/etc/ssh-keys" 35 | readOnly: true 36 | - name: labhub 37 | mountPath: "/etc/labhub" 38 | readOnly: true 39 | ports: 40 | - name: http 41 | containerPort: 8000 42 | protocol: TCP 43 | livenessProbe: 44 | httpGet: 45 | path: /check 46 | port: http 47 | readinessProbe: 48 | httpGet: 49 | path: /check 50 | port: http 51 | resources: 52 | {{- toYaml .Values.resources | nindent 12 }} 53 | volumes: 54 | - name: labhub-ssh-keys 55 | secret: 56 | secretName: labhub-ssh-keys 57 | - name: labhub 58 | configMap: 59 | name: {{ template "labhub.fullname" . }} 60 | {{- with .Values.nodeSelector }} 61 | nodeSelector: 62 | {{- toYaml . | nindent 8 }} 63 | {{- end }} 64 | {{- with .Values.affinity }} 65 | affinity: 66 | {{- toYaml . | nindent 8 }} 67 | {{- end }} 68 | {{- with .Values.tolerations }} 69 | tolerations: 70 | {{- toYaml . | nindent 8 }} 71 | {{- end }} 72 | -------------------------------------------------------------------------------- /src/api/gitlab_client.rs: -------------------------------------------------------------------------------- 1 | use crate::api::models::gitlab; 2 | use crate::config; 3 | use crate::errors::GitError; 4 | 5 | use log::error; 6 | use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; 7 | use reqwest; 8 | 9 | fn headers(token: &str) -> reqwest::header::HeaderMap { 10 | let token_header = reqwest::header::HeaderName::from_static("private-token"); 11 | let mut headers = reqwest::header::HeaderMap::new(); 12 | headers.insert( 13 | token_header, 14 | reqwest::header::HeaderValue::from_str(token).unwrap(), 15 | ); 16 | headers.insert( 17 | reqwest::header::ACCEPT, 18 | reqwest::header::HeaderValue::from_static("application/json"), 19 | ); 20 | headers.insert( 21 | reqwest::header::ACCEPT_ENCODING, 22 | reqwest::header::HeaderValue::from_static("Accept-Encoding: deflate, gzip"), 23 | ); 24 | headers 25 | } 26 | 27 | fn make_api_url(project: &str) -> String { 28 | let hostname = match config::CONFIG.gitlab.hostname.as_ref() { 29 | Some(hostname) => hostname.clone(), 30 | _ => "gitlab.com".to_string(), 31 | }; 32 | const FRAGMENT: &AsciiSet = &CONTROLS.add(b'/').add(b'%'); 33 | let project = utf8_percent_encode(project, FRAGMENT).to_string(); 34 | format!("https://{}/api/v4/projects/{}", hostname, project) 35 | } 36 | 37 | pub fn make_ext_url(project: &str) -> String { 38 | let hostname = match config::CONFIG.gitlab.hostname.as_ref() { 39 | Some(hostname) => hostname.clone(), 40 | _ => "gitlab.com".to_string(), 41 | }; 42 | format!("https://{}/{}", hostname, project) 43 | } 44 | 45 | pub fn get_pipelines( 46 | client: &reqwest::Client, 47 | project: &str, 48 | page: i64, 49 | per_page: i64, 50 | ) -> Result, GitError> { 51 | let res: Vec = client 52 | .get(&format!( 53 | "{}/pipelines?page={}&per_page={}", 54 | make_api_url(project), 55 | page, 56 | per_page 57 | )) 58 | .headers(headers(&config::CONFIG.gitlab.api_token)) 59 | .send()? 60 | .json()?; 61 | Ok(res) 62 | } 63 | 64 | pub fn retry_pipeline( 65 | client: &reqwest::Client, 66 | project: &str, 67 | pipeline_id: i64, 68 | ) -> Result<(), GitError> { 69 | let res = client 70 | .post(&format!( 71 | "{}/pipelines/{}/retry", 72 | make_api_url(project), 73 | pipeline_id 74 | )) 75 | .headers(headers(&config::CONFIG.gitlab.api_token)) 76 | .send()?; 77 | 78 | match res.status() { 79 | reqwest::StatusCode::CREATED => Ok(()), 80 | _ => { 81 | let msg = format!("Error retrying pipeline: {:#?}", res); 82 | error!("{}", msg); 83 | Err(GitError { message: msg }) 84 | } 85 | } 86 | } 87 | 88 | #[cfg(test)] 89 | mod test { 90 | use super::*; 91 | 92 | #[test] 93 | fn test_make_ext_url() { 94 | assert_eq!( 95 | make_ext_url("brndnmtthws-oss/conky"), 96 | "https://gitlab.com/brndnmtthws-oss/conky" 97 | ); 98 | } 99 | 100 | #[test] 101 | fn test_make_api_url() { 102 | assert_eq!( 103 | make_api_url("brndnmtthws-oss/conky"), 104 | "https://gitlab.com/api/v4/projects/brndnmtthws-oss%2Fconky" 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::commands; 2 | 3 | use log::info; 4 | use std::collections::HashMap; 5 | use std::env; 6 | use std::fs::File; 7 | use std::io::prelude::*; 8 | use std::sync::Mutex; 9 | use toml; 10 | use yansi::Paint; 11 | 12 | #[derive(Debug, Deserialize, PartialEq)] 13 | #[serde(rename_all = "snake_case")] 14 | pub enum Feature { 15 | ExternalPr, 16 | Commands, 17 | } 18 | 19 | #[derive(Debug, Deserialize)] 20 | pub struct Config { 21 | pub github: Site, 22 | pub gitlab: Site, 23 | pub mappings: Vec, 24 | pub features: Vec, 25 | pub commands: Commands, 26 | pub actions: Actions, 27 | } 28 | 29 | pub fn feature_enabled(feature: &Feature) -> bool { 30 | CONFIG.features.contains(&feature) 31 | } 32 | 33 | pub fn command_enabled(command: &commands::CommandAction) -> bool { 34 | feature_enabled(&Feature::Commands) && CONFIG.commands.enabled_commands.contains(&command) 35 | } 36 | 37 | pub fn action_enabled(action: &str) -> bool { 38 | CONFIG.actions.enabled_actions.contains(&action.to_string()) 39 | } 40 | 41 | #[derive(Debug, Deserialize)] 42 | pub struct Actions { 43 | pub enabled_actions: Vec, 44 | } 45 | 46 | #[derive(Debug, Deserialize)] 47 | pub struct Commands { 48 | pub enabled_commands: Vec, 49 | } 50 | 51 | #[derive(Debug, Deserialize)] 52 | pub struct Site { 53 | pub webhook_secret: String, 54 | pub username: String, 55 | pub ssh_key: String, 56 | pub api_token: String, 57 | pub hostname: Option, 58 | } 59 | 60 | #[derive(Debug, Deserialize)] 61 | pub struct Mapping { 62 | pub github_repo: String, 63 | pub gitlab_repo: String, 64 | } 65 | 66 | lazy_static! { 67 | pub static ref HUB_TO_LAB: Mutex> = { 68 | let m: HashMap = HashMap::new(); 69 | Mutex::new(m) 70 | }; 71 | } 72 | 73 | lazy_static! { 74 | pub static ref LAB_TO_HUB: Mutex> = { 75 | let m: HashMap = HashMap::new(); 76 | Mutex::new(m) 77 | }; 78 | } 79 | 80 | fn get_labhub_toml_path() -> String { 81 | env::var("LABHUB_TOML").unwrap_or_else(|_| "LabHub.toml".to_string()) 82 | } 83 | 84 | lazy_static! { 85 | pub static ref CONFIG: Config = { 86 | let labhub_toml_path = get_labhub_toml_path(); 87 | let config: Config = toml::from_str(&read_file_to_string(&labhub_toml_path)).unwrap(); 88 | config 89 | }; 90 | } 91 | 92 | fn read_file_to_string(filename: &str) -> String { 93 | let mut file = File::open(filename).expect("Unable to open the file"); 94 | let mut contents = String::new(); 95 | file.read_to_string(&mut contents) 96 | .expect("Unable to read the file"); 97 | contents 98 | } 99 | 100 | pub fn load_config() { 101 | info!( 102 | "Loaded LabHub configuration values from {}", 103 | get_labhub_toml_path() 104 | ); 105 | info!("CONFIG => {:#?}", Paint::red(&*CONFIG)); 106 | 107 | for mapping in CONFIG.mappings.iter() { 108 | let mut hub_to_lab_lock = HUB_TO_LAB.lock(); 109 | let hub_to_lab = hub_to_lab_lock.as_mut().unwrap(); 110 | hub_to_lab.insert(mapping.github_repo.clone(), mapping.gitlab_repo.clone()); 111 | 112 | let mut lab_to_hub_lock = LAB_TO_HUB.lock(); 113 | let lab_to_hub = lab_to_hub_lock.as_mut().unwrap(); 114 | lab_to_hub.insert(mapping.gitlab_repo.clone(), mapping.github_repo.clone()); 115 | } 116 | info!( 117 | "HUB_TO_LAB => {:#?}", 118 | Paint::red(HUB_TO_LAB.lock().unwrap()) 119 | ); 120 | info!( 121 | "LAB_TO_HUB => {:#?}", 122 | Paint::red(LAB_TO_HUB.lock().unwrap()) 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /src/api/models/gen_models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pprint import pprint 4 | import click 5 | from jinja2 import Environment, FileSystemLoader 6 | env = Environment( 7 | loader=FileSystemLoader('templates'), 8 | ) 9 | 10 | 11 | def to_camel_case(snake_str): 12 | components = snake_str.split('_') 13 | return ''.join(x.title() for x in components) 14 | 15 | 16 | def get_type_for(name, value): 17 | if name == 'created_at' \ 18 | or name == 'updated_at' \ 19 | or name == 'pushed_at' \ 20 | or name == 'due_on' \ 21 | or name == 'closed_at': 22 | return "serde_json::value::Value" 23 | elif value is None: 24 | return "String" 25 | elif type(value) is int: 26 | return "i64" 27 | elif type(value) is bool: 28 | return "bool" 29 | elif type(value) is str: 30 | return "String" 31 | elif type(value) is float: 32 | return "f64" 33 | return "String" 34 | 35 | 36 | def generate_type(key_name, value): 37 | return "pub {}: Option<{}>,".format(key_name, get_type_for(key_name, value)) 38 | 39 | 40 | def check_for_keywords(key): 41 | if key == 'ref' or key == 'type' or key == 'self': 42 | return '{}_key'.format(key) 43 | return key 44 | 45 | 46 | def generate_structs(structs, struct_name, key, value, skip=False): 47 | key_name = check_for_keywords(key) 48 | 49 | if struct_name not in structs: 50 | structs[struct_name] = [] 51 | 52 | if type(value) is not list and type(value) is not dict: 53 | obj = generate_type(key_name, value) 54 | if key_name != key: 55 | structs[struct_name].append('#[serde(rename = "{}")]'.format(key)) 56 | structs[struct_name].append(obj) 57 | elif type(value) is list and len(value) > 0 and type(value[0]) is dict: 58 | substruct_name = struct_name + to_camel_case(key) + "Item" 59 | if struct_name.endswith("s"): 60 | substruct_name = struct_name[:-1] + to_camel_case(key) + "Item" 61 | for subkey, subvalue in value[0].items(): 62 | structs = generate_structs( 63 | structs, substruct_name, subkey, subvalue) 64 | if key_name != key: 65 | structs[struct_name].append('#[serde(rename = "{}")]'.format(key)) 66 | structs[struct_name].append( 67 | "pub {}: Option>,".format(key_name, substruct_name)) 68 | elif type(value) is list and len(value) > 0: 69 | if key_name != key: 70 | structs[struct_name].append('#[serde(rename = "{}")]'.format(key)) 71 | structs[struct_name].append("pub {}: Option>,".format( 72 | key_name, get_type_for(key_name, value[0]))) 73 | elif type(value) is dict: 74 | substruct_name = struct_name + to_camel_case(key_name) 75 | for subkey, subvalue in value.items(): 76 | structs = generate_structs( 77 | structs, substruct_name, subkey, subvalue) 78 | if not skip: 79 | if key_name != key: 80 | structs[struct_name].append( 81 | '#[serde(rename = "{}")]'.format(key)) 82 | structs[struct_name].append( 83 | "pub {}: Option<{}>,".format(key_name, substruct_name)) 84 | 85 | return structs 86 | 87 | 88 | @click.command() 89 | @click.argument('input_json', type=click.File('rb')) 90 | @click.argument('output_rs', type=click.File('wb')) 91 | @click.argument('mod_name') 92 | def inout(input_json, output_rs, mod_name): 93 | import json 94 | data = json.load(input_json) 95 | structs = {} 96 | for event_type, model in data.items(): 97 | if type(model) is dict: 98 | for key, value in model.items(): 99 | structs = generate_structs( 100 | structs, to_camel_case(event_type), key, value) 101 | elif type(model) is list: 102 | structs = generate_structs( 103 | structs, to_camel_case(event_type), event_type, model[0], skip=True) 104 | template = env.get_template('model.rs.j2') 105 | output_rs.write(bytearray(template.render( 106 | mod_name=mod_name, structs=structs), 'utf8')) 107 | print("Wrote to {}".format(output_rs.name)) 108 | 109 | 110 | if __name__ == '__main__': 111 | # This is just to make the linter happy. 112 | inout(None, None, None, None) 113 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use crate::api::github_signature; 2 | use crate::commands; 3 | 4 | use rocket::request::Request; 5 | use rocket::response::content; 6 | use std::io; 7 | 8 | #[derive(Debug, Responder)] 9 | #[response(status = 500, content_type = "json")] 10 | pub struct ResponseError { 11 | response: content::Json, 12 | } 13 | 14 | #[derive(Debug, Responder)] 15 | #[response(status = 400, content_type = "json")] 16 | pub struct BadRequest { 17 | response: content::Json, 18 | } 19 | 20 | #[derive(Debug, Responder)] 21 | pub enum RequestErrorResult { 22 | #[response(status = 400, content_type = "json")] 23 | BadRequest(BadRequest), 24 | #[response(status = 500, content_type = "json")] 25 | ResponseError(ResponseError), 26 | } 27 | 28 | #[derive(Debug)] 29 | pub struct GitError { 30 | pub message: String, 31 | } 32 | 33 | impl From for RequestErrorResult { 34 | fn from(error: io::Error) -> Self { 35 | RequestErrorResult::ResponseError { 36 | 0: ResponseError { 37 | response: content::Json(json!({ "error": format!("{:?}", error) }).to_string()), 38 | }, 39 | } 40 | } 41 | } 42 | 43 | impl From for RequestErrorResult { 44 | fn from(error: github_signature::SignatureError) -> Self { 45 | RequestErrorResult::BadRequest { 46 | 0: BadRequest { 47 | response: content::Json(json!({ "error": format!("{:?}", error) }).to_string()), 48 | }, 49 | } 50 | } 51 | } 52 | 53 | impl From for RequestErrorResult { 54 | fn from(error: std::option::NoneError) -> Self { 55 | RequestErrorResult::BadRequest { 56 | 0: BadRequest { 57 | response: content::Json(json!({ "error": format!("{:?}", error) }).to_string()), 58 | }, 59 | } 60 | } 61 | } 62 | 63 | impl From for RequestErrorResult { 64 | fn from(error: serde_json::error::Error) -> Self { 65 | RequestErrorResult::BadRequest { 66 | 0: BadRequest { 67 | response: content::Json(json!({ "error": format!("{:?}", error) }).to_string()), 68 | }, 69 | } 70 | } 71 | } 72 | 73 | impl From for GitError { 74 | fn from(error: git2::Error) -> Self { 75 | GitError { 76 | message: format!("Git error: {:?}", error.message()), 77 | } 78 | } 79 | } 80 | 81 | impl From for GitError { 82 | fn from(error: std::option::NoneError) -> Self { 83 | GitError { 84 | message: format!("Git error: {:?}", error), 85 | } 86 | } 87 | } 88 | 89 | impl From for GitError { 90 | fn from(error: io::Error) -> Self { 91 | GitError { 92 | message: format!("Git error: {:?}", error), 93 | } 94 | } 95 | } 96 | 97 | impl From for GitError { 98 | fn from(error: serde_json::error::Error) -> Self { 99 | GitError { 100 | message: format!("Github serde error: {:?}", error), 101 | } 102 | } 103 | } 104 | 105 | impl From for GitError { 106 | fn from(error: reqwest::Error) -> Self { 107 | GitError { 108 | message: format!("Git request error: {:?}", error), 109 | } 110 | } 111 | } 112 | 113 | impl From for GitError { 114 | fn from(error: commands::CommandError) -> Self { 115 | GitError { 116 | message: format!("Git command error: {:?}", error), 117 | } 118 | } 119 | } 120 | 121 | #[catch(404)] 122 | pub fn not_found(req: &Request) -> content::Json { 123 | content::Json( 124 | json!({ 125 | "error": 126 | format!( 127 | "Look elsewhere, perhaps? No matching route for uri={}", 128 | req.uri() 129 | ) 130 | }) 131 | .to_string(), 132 | ) 133 | } 134 | 135 | #[catch(500)] 136 | pub fn internal_server_error(_req: &Request) -> content::Json { 137 | content::Json( 138 | json!({ 139 | "error":"Internal server error 🤖" 140 | }) 141 | .to_string(), 142 | ) 143 | } 144 | 145 | #[catch(422)] 146 | pub fn unprocessable_entity(_req: &Request) -> content::Json { 147 | content::Json( 148 | json!({ 149 | "error":"The request was well-formed but was unable to be followed due to semantic errors." 150 | }) 151 | .to_string(), 152 | ) 153 | } 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![pipeline status](https://gitlab.com/brndnmtthws-oss/labhub/badges/master/pipeline.svg)](https://gitlab.com/brndnmtthws-oss/labhub/commits/master) [![codecov](https://codecov.io/gh/brndnmtthws/labhub/branch/master/graph/badge.svg)](https://codecov.io/gh/brndnmtthws/labhub) [![Current Crates.io Version](https://img.shields.io/crates/v/labhub.svg)](https://crates.io/crates/labhub) 2 | 3 | # 🤖 LabHub 4 | 5 | A GitHub bot written in Rust for using GitLab CI in OSS projects. 6 | 7 | ## Features 8 | 9 | - Listens for webhooks from GitHub 10 | - Pushes branches to GitLab from external (forked) PRs 11 | - Accepts commands by way of PR comments 12 | - Possibly more coming soon 👻 13 | 14 | ### Commands 15 | 16 | Commands can be executed by commenting on a PR with your CI user's login. 17 | 18 | - **`@labhub retry`**: retry a pipeline that has failed 19 | 20 | ## The Problem 21 | 22 | GitLab has a great CI system, however it's not suitable for open source projects 😧 (at the time of writing) because it won't build external PRs by default. There are security concerns about the risk of exposing secrets in external builds, and GitLab errs on the side of caution by not building external PRs by default. 23 | 24 | For more details on the issue, [please take a look at this GitLab discussion](https://gitlab.com/gitlab-org/gitlab-ee/issues/5667). 25 | 26 | ## ✨ The Solution 27 | 28 | If you're not concerned with leaking secrets, then LabHub may be for you! LabHub listens for webhooks from GitHub to notify for new pull requests. If the PR is from a forked repo, it will push a branch to GitLab (for the corresponding PR) to test the build. 29 | 30 | ## 🏃‍♀️ In Action 31 | 32 | **Using LabHub? Open a PR to add your project here! 😀** 33 | 34 | LabHub is currently being used by the following projects: 35 | 36 | - [Conky](https://github.com/brndnmtthws/conky) 37 | - [DOMjudge](https://github.com/domjudge/domjudge) 38 | 39 | ## Compiling 40 | 41 | LabHub requires Rust nightly. To compile using [`rustup`](https://rustup.rs/): 42 | 43 | ```ShellSession 44 | $ rustup toolchain install nightly 45 | $ rustup default nightly 46 | $ cargo build 47 | ``` 48 | 49 | Be sure to switch back to `stable` with `rustup default stable` if that's your preferred toolchain. 50 | 51 | ## 🎛 Configuration 52 | 53 | LabHub is configured using [`LabHub.toml`](LabHub.toml). For details, see [src/config.rs](src/config.rs). You can specify the path to `LabHub.toml` by setting the `LABHUB_TOML` environment variable. 54 | 55 | ## 🚀 Deployment 56 | 57 | ### Setup Webhooks 58 | 59 | You'll need to set up webhooks for any repo you wish to enable LabHub for. Currently, only GitHub webhooks are required. To get started, go to `github.com///settings/hooks` and add a new webhook. 60 | 61 | Configure the webhook to send PR and push events. 62 | 63 | - Set the payload URL path to `/github/events`, which is the path LabHub is expecting for GitHub events. 64 | - Create a secret (ex: `cat /dev/urandom | LC_CTYPE=C tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1`) and set the same value in the webhook config as in LabHub. 65 | - Make sure the payload type is `application/json`. 66 | - [Here's how your webhook should look](docs/github-webhook-config.png) 67 | 68 | ### Create SSH keys 69 | 70 | You'll need a CI user with SSH keys for both GitHub and GitLab. Create an account on both sites (if you don't already have a CI user), and create an SSH key for LabHub: 71 | 72 | ```ShellSession 73 | $ ssh-keygen -f labhub-key.ecdsa -t ecdsa -b 521 74 | ``` 75 | 76 | Keep `labhub-key.ecdsa` safe, and upload `labhub-key.ecdsa.pub` to both GitHub and GitLab for the CI user. 77 | 78 | ### Create Personal Access Tokens 79 | 80 | Create personal access tokens for your CI user on both GitHub, and GitLab. Supply these tokens by setting the `api_token` parameter in `LabHub.toml` for both GitHub and GitLab. 81 | 82 | #### Personal Access Token for GitHub 83 | 84 | - Go to https://github.com/settings/tokens 85 | - Click "Generate new token" 86 | - Give the token a name, and [enable the `repo` scope, like this](docs/github-personal-access-token.png). 87 | - Save that token to your `LabHub.toml` 88 | 89 | #### Personal Access Token for GitLab 90 | 91 | - Go to https://gitlab.com/profile/personal_access_tokens 92 | - Give the token a name, and [enable the `api` scope, like this](docs/gitlab-personal-access-token.png). 93 | - Save that token to your `LabHub.toml` 94 | 95 | ### Deploy to Kubernetes with Helm 96 | 97 | There's a Helm chart included in this repo, which is the preferred method of deployment. To use you, you must first create the SSH key secrets with kubectl. Assuming your SSH private key is `labhub-key.ecdsa`: 98 | 99 | ```ShellSession 100 | $ kubectl create secret generic labhub-ssh-keys --from-file=github=labhub-key.ecdsa --from-file=gitlab=labhub-key.ecdsa 101 | ``` 102 | 103 | You may use separate keys for GitHub and GitLab if you choose, respectively. 104 | 105 | Once you have the secrets, install the helm chart from [helm/labhub/](helm/labhub/): 106 | 107 | ```ShellSession 108 | $ cd helm/labhub/ 109 | $ cp values.yaml myvalues.yaml 110 | ### Edit myvalues.yaml to your liking ### 111 | $ helm upgrade --install labhub . -f myvalues.yaml 112 | ``` 113 | 114 | ### Not implemented: 115 | 116 | - No periodic reconciling of GitLab branches with open PRs: if a webhook is missed for any reason, the GitLab pipeline may not correctly reflect the PR state 117 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use std::convert::TryFrom; 3 | 4 | fn tokenize_comment(body: &str) -> Vec<&str> { 5 | body.split_whitespace().collect() 6 | } 7 | 8 | #[derive(Debug, PartialEq, Deserialize)] 9 | #[serde(rename_all = "camelCase")] 10 | pub enum CommandAction { 11 | Retry, 12 | } 13 | 14 | #[derive(Debug, PartialEq)] 15 | pub struct Command { 16 | pub username: String, 17 | pub command: CommandAction, 18 | pub args: Vec, 19 | } 20 | 21 | #[derive(Debug, PartialEq)] 22 | pub enum CommandError { 23 | BadUsername, 24 | UnknownCommand, 25 | InvalidLength, 26 | InvalidFormat, 27 | } 28 | 29 | pub fn parse_body(body: &str, for_username: &str) -> Result { 30 | Command::parse_from(body, for_username) 31 | } 32 | 33 | impl TryFrom<&str> for CommandAction { 34 | type Error = CommandError; 35 | fn try_from(body: &str) -> Result { 36 | match body.to_lowercase().as_ref() { 37 | "retry" => Ok(CommandAction::Retry), 38 | _ => Err(CommandError::UnknownCommand), 39 | } 40 | } 41 | } 42 | 43 | impl Command { 44 | fn parse_from(body: &str, for_username: &str) -> Result { 45 | let tokens = tokenize_comment(body); 46 | if tokens.len() < 2 { 47 | return Err(CommandError::InvalidLength); 48 | } 49 | lazy_static! { 50 | static ref RE: Regex = Regex::new("^@(.*)$").unwrap(); 51 | } 52 | Ok(Command { 53 | username: match RE.captures(tokens[0]) { 54 | Some(cap) => { 55 | let username = cap[1].to_string(); 56 | if username != "labhub" && username != for_username { 57 | return Err(CommandError::BadUsername); 58 | } else { 59 | username 60 | } 61 | } 62 | _ => return Err(CommandError::InvalidFormat), 63 | }, 64 | command: CommandAction::try_from(tokens[1])?, 65 | args: tokens 66 | .iter() 67 | .skip(2) 68 | .map(std::string::ToString::to_string) 69 | .collect(), 70 | }) 71 | } 72 | } 73 | 74 | #[cfg(test)] 75 | mod test { 76 | use super::*; 77 | use crate::testing::run_test; 78 | 79 | #[test] 80 | fn test_tokenize_comment() { 81 | run_test(|| { 82 | assert_eq!(tokenize_comment("hello fren"), vec!["hello", "fren"]); 83 | assert_eq!(tokenize_comment(" hello fren"), vec!["hello", "fren"]); 84 | assert_eq!(tokenize_comment("hello fren "), vec!["hello", "fren"]); 85 | assert_eq!( 86 | tokenize_comment(" hello fren "), 87 | vec!["hello", "fren"] 88 | ); 89 | }); 90 | } 91 | 92 | #[test] 93 | fn test_from_string() { 94 | run_test(|| { 95 | assert_eq!( 96 | Command::parse_from("lol", "bot").unwrap_err(), 97 | CommandError::InvalidLength 98 | ); 99 | assert_eq!( 100 | Command::parse_from("herp derp nerp", "bot").unwrap_err(), 101 | CommandError::InvalidFormat 102 | ); 103 | assert_eq!( 104 | Command::parse_from("@bot derp nerp", "bot").unwrap_err(), 105 | CommandError::UnknownCommand 106 | ); 107 | assert_eq!( 108 | Command::parse_from("@bot retry nerp", "bot") 109 | .unwrap() 110 | .command, 111 | CommandAction::Retry 112 | ); 113 | assert_eq!( 114 | Command::parse_from("@bot retry nerp", "bot").unwrap().args, 115 | vec!["nerp"] 116 | ); 117 | }); 118 | } 119 | 120 | #[test] 121 | fn test_is_valid() { 122 | run_test(|| { 123 | let command = Command::parse_from("@bot retry nerp", "bot"); 124 | assert_eq!(command.is_ok(), true); 125 | assert_eq!( 126 | command.ok(), 127 | Some(Command { 128 | username: "bot".into(), 129 | command: CommandAction::Retry, 130 | args: vec!["nerp".to_string()] 131 | }) 132 | ); 133 | }); 134 | } 135 | 136 | #[test] 137 | fn test_wrong_username() { 138 | run_test(|| { 139 | let command = Command::parse_from("@not retry nerp", "bot"); 140 | assert_eq!(command.is_err(), true); 141 | assert_eq!(command.err(), Some(CommandError::BadUsername)); 142 | }); 143 | } 144 | 145 | #[test] 146 | fn test_parse_body() { 147 | run_test(|| { 148 | assert_eq!( 149 | parse_body("@bot retry nerp", "bot").unwrap().command, 150 | CommandAction::Retry 151 | ); 152 | // Allow use of @labhub always 153 | assert_eq!( 154 | parse_body("@labhub retry nerp", "bot").unwrap().command, 155 | CommandAction::Retry 156 | ); 157 | assert_eq!( 158 | parse_body("@not retry nerp", "bot").unwrap_err(), 159 | CommandError::BadUsername 160 | ); 161 | }); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | stages: 3 | - dependencies 4 | - build 5 | - test 6 | - checks 7 | - publish 8 | 9 | variables: 10 | SCCACHE_GCS_BUCKET: btm-build-cache 11 | SCCACHE_GCS_RW_MODE: READ_WRITE 12 | SCCACHE_GCS_KEY_PATH: /tmp/storage-key.json 13 | DOCKER_DRIVER: overlay2 14 | CARGO_HOME: $CI_PROJECT_DIR/.cargo 15 | 16 | before_script: 17 | - echo $GCS_STORAGE_KEY > $SCCACHE_GCS_KEY_PATH 18 | 19 | create builder: 20 | stage: dependencies 21 | retry: 22 | max: 2 23 | when: 24 | - runner_system_failure 25 | - stuck_or_timeout_failure 26 | - unknown_failure 27 | - api_failure 28 | allow_failure: true 29 | image: docker:stable 30 | only: 31 | refs: 32 | - master 33 | services: 34 | - docker:dind 35 | before_script: 36 | - set -- $CI_JOB_NAME 37 | - export DOCKER_HOST="${DOCKER_HOST:-tcp://localhost:2375/}" 38 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY 39 | - docker pull $CI_REGISTRY_IMAGE/builder:latest || true 40 | - docker pull rustlang/rust:nightly || true 41 | script: 42 | - > 43 | docker build -f Dockerfile.builder 44 | --cache-from $CI_REGISTRY_IMAGE/builder:latest 45 | --cache-from rustlang/rust:nightly 46 | --tag $CI_REGISTRY_IMAGE/builder:latest 47 | . 48 | - docker push $CI_REGISTRY_IMAGE/builder:latest 49 | 50 | .build_script: &build_script 51 | stage: build 52 | retry: 53 | max: 2 54 | when: 55 | - runner_system_failure 56 | - stuck_or_timeout_failure 57 | - unknown_failure 58 | - api_failure 59 | image: $CI_REGISTRY_IMAGE/builder:latest 60 | script: 61 | - set -- $CI_JOB_NAME 62 | - export TARGET=$1 63 | - echo Running build for TARGET=$TARGET 64 | - cargo build --verbose --all 65 | artifacts: 66 | paths: 67 | - target/ 68 | expire_in: 30 days 69 | cache: 70 | key: ${CI_COMMIT_REF_SLUG} 71 | paths: 72 | - .cargo/registry 73 | 74 | i686-apple-darwin build: *build_script 75 | i686-pc-windows-gnu build: *build_script 76 | i686-pc-windows-msvc build: *build_script 77 | i686-unknown-linux-gnu build: *build_script 78 | x86_64-apple-darwin build: *build_script 79 | x86_64-pc-windows-gnu build: *build_script 80 | x86_64-pc-windows-msvc build: *build_script 81 | x86_64-unknown-freebsd build: *build_script 82 | x86_64-unknown-linux-gnu build: *build_script 83 | 84 | .test_script: &test_script 85 | stage: test 86 | retry: 87 | max: 2 88 | when: 89 | - runner_system_failure 90 | - stuck_or_timeout_failure 91 | - unknown_failure 92 | - api_failure 93 | image: $CI_REGISTRY_IMAGE/builder:latest 94 | script: 95 | - set -- $CI_JOB_NAME 96 | - export TARGET=$1 97 | - cargo test --verbose --all --target $TARGET 98 | - | 99 | if [[ "$TARGET" == "x86_64-unknown-linux-gnu" ]]; then 100 | cargo tarpaulin --out Xml && bash <(curl -s https://codecov.io/bash) 101 | fi 102 | coverage: '/(\d+.\d+%) coverage,/' 103 | cache: 104 | key: ${CI_COMMIT_REF_SLUG} 105 | paths: 106 | - .cargo/registry 107 | 108 | x86_64-unknown-linux-gnu test: 109 | <<: *test_script 110 | dependencies: 111 | - x86_64-unknown-linux-gnu build 112 | 113 | clippy: 114 | stage: checks 115 | retry: 116 | max: 2 117 | when: 118 | - runner_system_failure 119 | - stuck_or_timeout_failure 120 | - unknown_failure 121 | - api_failure 122 | image: $CI_REGISTRY_IMAGE/builder:latest 123 | script: 124 | - cargo clippy --all-targets --all-features -- -D warnings 125 | dependencies: 126 | - x86_64-unknown-linux-gnu build 127 | cache: 128 | key: ${CI_COMMIT_REF_SLUG} 129 | paths: 130 | - .cargo/registry 131 | 132 | rustfmt: 133 | stage: checks 134 | retry: 135 | max: 2 136 | when: 137 | - runner_system_failure 138 | - stuck_or_timeout_failure 139 | - unknown_failure 140 | - api_failure 141 | image: $CI_REGISTRY_IMAGE/builder:latest 142 | script: 143 | - cargo fmt --all -- --check 144 | dependencies: 145 | - x86_64-unknown-linux-gnu build 146 | cache: 147 | key: ${CI_COMMIT_REF_SLUG} 148 | paths: 149 | - .cargo/registry 150 | 151 | cargo publish: 152 | retry: 153 | max: 2 154 | when: 155 | - runner_system_failure 156 | - stuck_or_timeout_failure 157 | - unknown_failure 158 | - api_failure 159 | only: 160 | - tags 161 | variables: 162 | CARGO_HOME: /usr/local/cargo 163 | stage: publish 164 | image: $CI_REGISTRY_IMAGE/builder:latest 165 | script: 166 | - cargo publish --token $CRATESIO_TOKEN 167 | dependencies: 168 | - x86_64-unknown-linux-gnu build 169 | 170 | .github_deploy: &github_deploy 171 | retry: 172 | max: 2 173 | when: 174 | - runner_system_failure 175 | - stuck_or_timeout_failure 176 | - unknown_failure 177 | - api_failure 178 | only: 179 | - tags 180 | stage: publish 181 | image: $CI_REGISTRY_IMAGE/builder:latest 182 | script: 183 | - set -- $CI_JOB_NAME 184 | - export TARGET=$1 185 | - cargo build --release 186 | - RELEASE_NAME="labhub-$CI_COMMIT_TAG-$TARGET" 187 | - mkdir $RELEASE_NAME 188 | - cp target/release/labhub $RELEASE_NAME/ 189 | - cp README.md LICENSE $RELEASE_NAME/ 190 | - tar czvf $RELEASE_NAME.tar.gz $RELEASE_NAME 191 | - echo -n $(shasum -ba 256 "$RELEASE_NAME.tar.gz" | cut -d " " -f 1) > $RELEASE_NAME.tar.gz.sha256 192 | - github-release release -u brndnmtthws -r labhub -t $CI_COMMIT_TAG --name "LabHub $CI_COMMIT_TAG" 193 | - github-release upload -u brndnmtthws -r labhub -t $CI_COMMIT_TAG --name $RELEASE_NAME.tar.gz -f $RELEASE_NAME.tar.gz 194 | - github-release upload -u brndnmtthws -r labhub -t $CI_COMMIT_TAG --name $RELEASE_NAME.tar.gz.sha256 -f $RELEASE_NAME.tar.gz.sha256 195 | dependencies: 196 | - x86_64-unknown-linux-gnu build 197 | cache: 198 | key: ${CI_COMMIT_REF_SLUG} 199 | paths: 200 | - .cargo/registry 201 | 202 | x86_64-unknown-linux-gnu publish: *github_deploy 203 | -------------------------------------------------------------------------------- /src/testdata/github_created_issue_comment.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "issue": { 4 | "url": "https://api.github.com/repos/brndnmtthws/labhub/issues/8", 5 | "repository_url": "https://api.github.com/repos/brndnmtthws/labhub", 6 | "labels_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/8/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/8/comments", 8 | "events_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/8/events", 9 | "html_url": "https://github.com/brndnmtthws/labhub/pull/8", 10 | "id": 418492922, 11 | "node_id": "MDExOlB1bGxSZXF1ZXN0MjU5MjU0MzIz", 12 | "number": 8, 13 | "title": "Update .gitlab-ci.yml", 14 | "user": { 15 | "login": "brndnmtthws", 16 | "id": 3129093, 17 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 18 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 19 | "gravatar_id": "", 20 | "url": "https://api.github.com/users/brndnmtthws", 21 | "html_url": "https://github.com/brndnmtthws", 22 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 23 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 24 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 25 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 26 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 27 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 28 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 29 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 30 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 31 | "type": "User", 32 | "site_admin": false 33 | }, 34 | "labels": [ 35 | 36 | ], 37 | "state": "open", 38 | "locked": false, 39 | "assignee": null, 40 | "assignees": [ 41 | 42 | ], 43 | "milestone": null, 44 | "comments": 0, 45 | "created_at": "2019-03-07T20:13:37Z", 46 | "updated_at": "2019-03-07T20:15:14Z", 47 | "closed_at": null, 48 | "author_association": "OWNER", 49 | "pull_request": { 50 | "url": "https://api.github.com/repos/brndnmtthws/labhub/pulls/8", 51 | "html_url": "https://github.com/brndnmtthws/labhub/pull/8", 52 | "diff_url": "https://github.com/brndnmtthws/labhub/pull/8.diff", 53 | "patch_url": "https://github.com/brndnmtthws/labhub/pull/8.patch" 54 | }, 55 | "body": "" 56 | }, 57 | "comment": { 58 | "url": "https://api.github.com/repos/brndnmtthws/labhub/issues/comments/470677118", 59 | "html_url": "https://github.com/brndnmtthws/labhub/pull/8#issuecomment-470677118", 60 | "issue_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/8", 61 | "id": 470677118, 62 | "node_id": "MDEyOklzc3VlQ29tbWVudDQ3MDY3NzExOA==", 63 | "user": { 64 | "login": "brndnmtthws", 65 | "id": 3129093, 66 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 67 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 68 | "gravatar_id": "", 69 | "url": "https://api.github.com/users/brndnmtthws", 70 | "html_url": "https://github.com/brndnmtthws", 71 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 72 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 73 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 74 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 75 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 76 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 77 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 78 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 79 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 80 | "type": "User", 81 | "site_admin": false 82 | }, 83 | "created_at": "2019-03-07T20:15:14Z", 84 | "updated_at": "2019-03-07T20:15:14Z", 85 | "author_association": "OWNER", 86 | "body": "hello test" 87 | }, 88 | "repository": { 89 | "id": 172714879, 90 | "node_id": "MDEwOlJlcG9zaXRvcnkxNzI3MTQ4Nzk=", 91 | "name": "labhub", 92 | "full_name": "brndnmtthws/labhub", 93 | "private": false, 94 | "owner": { 95 | "login": "brndnmtthws", 96 | "id": 3129093, 97 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 98 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 99 | "gravatar_id": "", 100 | "url": "https://api.github.com/users/brndnmtthws", 101 | "html_url": "https://github.com/brndnmtthws", 102 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 103 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 104 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 105 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 106 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 107 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 108 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 109 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 110 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 111 | "type": "User", 112 | "site_admin": false 113 | }, 114 | "html_url": "https://github.com/brndnmtthws/labhub", 115 | "description": "GitHub bot for using GitLab CI in OSS projects", 116 | "fork": false, 117 | "url": "https://api.github.com/repos/brndnmtthws/labhub", 118 | "forks_url": "https://api.github.com/repos/brndnmtthws/labhub/forks", 119 | "keys_url": "https://api.github.com/repos/brndnmtthws/labhub/keys{/key_id}", 120 | "collaborators_url": "https://api.github.com/repos/brndnmtthws/labhub/collaborators{/collaborator}", 121 | "teams_url": "https://api.github.com/repos/brndnmtthws/labhub/teams", 122 | "hooks_url": "https://api.github.com/repos/brndnmtthws/labhub/hooks", 123 | "issue_events_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/events{/number}", 124 | "events_url": "https://api.github.com/repos/brndnmtthws/labhub/events", 125 | "assignees_url": "https://api.github.com/repos/brndnmtthws/labhub/assignees{/user}", 126 | "branches_url": "https://api.github.com/repos/brndnmtthws/labhub/branches{/branch}", 127 | "tags_url": "https://api.github.com/repos/brndnmtthws/labhub/tags", 128 | "blobs_url": "https://api.github.com/repos/brndnmtthws/labhub/git/blobs{/sha}", 129 | "git_tags_url": "https://api.github.com/repos/brndnmtthws/labhub/git/tags{/sha}", 130 | "git_refs_url": "https://api.github.com/repos/brndnmtthws/labhub/git/refs{/sha}", 131 | "trees_url": "https://api.github.com/repos/brndnmtthws/labhub/git/trees{/sha}", 132 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub/statuses/{sha}", 133 | "languages_url": "https://api.github.com/repos/brndnmtthws/labhub/languages", 134 | "stargazers_url": "https://api.github.com/repos/brndnmtthws/labhub/stargazers", 135 | "contributors_url": "https://api.github.com/repos/brndnmtthws/labhub/contributors", 136 | "subscribers_url": "https://api.github.com/repos/brndnmtthws/labhub/subscribers", 137 | "subscription_url": "https://api.github.com/repos/brndnmtthws/labhub/subscription", 138 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub/commits{/sha}", 139 | "git_commits_url": "https://api.github.com/repos/brndnmtthws/labhub/git/commits{/sha}", 140 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub/comments{/number}", 141 | "issue_comment_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/comments{/number}", 142 | "contents_url": "https://api.github.com/repos/brndnmtthws/labhub/contents/{+path}", 143 | "compare_url": "https://api.github.com/repos/brndnmtthws/labhub/compare/{base}...{head}", 144 | "merges_url": "https://api.github.com/repos/brndnmtthws/labhub/merges", 145 | "archive_url": "https://api.github.com/repos/brndnmtthws/labhub/{archive_format}{/ref}", 146 | "downloads_url": "https://api.github.com/repos/brndnmtthws/labhub/downloads", 147 | "issues_url": "https://api.github.com/repos/brndnmtthws/labhub/issues{/number}", 148 | "pulls_url": "https://api.github.com/repos/brndnmtthws/labhub/pulls{/number}", 149 | "milestones_url": "https://api.github.com/repos/brndnmtthws/labhub/milestones{/number}", 150 | "notifications_url": "https://api.github.com/repos/brndnmtthws/labhub/notifications{?since,all,participating}", 151 | "labels_url": "https://api.github.com/repos/brndnmtthws/labhub/labels{/name}", 152 | "releases_url": "https://api.github.com/repos/brndnmtthws/labhub/releases{/id}", 153 | "deployments_url": "https://api.github.com/repos/brndnmtthws/labhub/deployments", 154 | "created_at": "2019-02-26T13:16:44Z", 155 | "updated_at": "2019-03-07T13:08:58Z", 156 | "pushed_at": "2019-03-07T20:13:38Z", 157 | "git_url": "git://github.com/brndnmtthws/labhub.git", 158 | "ssh_url": "git@github.com:brndnmtthws/labhub.git", 159 | "clone_url": "https://github.com/brndnmtthws/labhub.git", 160 | "svn_url": "https://github.com/brndnmtthws/labhub", 161 | "homepage": "", 162 | "size": 464, 163 | "stargazers_count": 1, 164 | "watchers_count": 1, 165 | "language": "Rust", 166 | "has_issues": true, 167 | "has_projects": true, 168 | "has_downloads": true, 169 | "has_wiki": true, 170 | "has_pages": false, 171 | "forks_count": 0, 172 | "mirror_url": null, 173 | "archived": false, 174 | "open_issues_count": 1, 175 | "license": { 176 | "key": "unlicense", 177 | "name": "The Unlicense", 178 | "spdx_id": "Unlicense", 179 | "url": "https://api.github.com/licenses/unlicense", 180 | "node_id": "MDc6TGljZW5zZTE1" 181 | }, 182 | "forks": 0, 183 | "open_issues": 1, 184 | "watchers": 1, 185 | "default_branch": "master" 186 | }, 187 | "sender": { 188 | "login": "brndnmtthws", 189 | "id": 3129093, 190 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 191 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 192 | "gravatar_id": "", 193 | "url": "https://api.github.com/users/brndnmtthws", 194 | "html_url": "https://github.com/brndnmtthws", 195 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 196 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 197 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 198 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 199 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 200 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 201 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 202 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 203 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 204 | "type": "User", 205 | "site_admin": false 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/github.rs: -------------------------------------------------------------------------------- 1 | use crate::api::models::github; 2 | use crate::api::{github_client, gitlab_client}; 3 | use crate::commands; 4 | use crate::config; 5 | use crate::errors::{GitError, RequestErrorResult}; 6 | 7 | use git2::build::RepoBuilder; 8 | use git2::{FetchOptions, PushOptions, RemoteCallbacks, Repository}; 9 | use log::{debug, error, info, warn}; 10 | use std::collections::HashMap; 11 | use std::path::Path; 12 | use std::sync::Mutex; 13 | use std::thread; 14 | use tempfile::{tempdir, TempDir}; 15 | 16 | #[cfg(test)] 17 | use mockers_derive::mocked; 18 | 19 | struct RepoData { 20 | repo: Repository, 21 | #[allow(dead_code)] 22 | dir: TempDir, 23 | } 24 | 25 | lazy_static! { 26 | static ref REPOS: Mutex> = { 27 | #[allow(unused_mut)] 28 | let mut m: HashMap = HashMap::new(); 29 | Mutex::new(m) 30 | }; 31 | } 32 | 33 | fn get_gitlab_repo_name(github_repo_full_name: &str) -> String { 34 | let hub_to_lab_lock = config::HUB_TO_LAB.lock().unwrap(); 35 | let hub_to_lab = &*hub_to_lab_lock; 36 | if hub_to_lab.contains_key(github_repo_full_name) { 37 | hub_to_lab.get(github_repo_full_name).unwrap().to_string() 38 | } else { 39 | github_repo_full_name.to_string() 40 | } 41 | } 42 | 43 | fn get_remote_callbacks(site: &config::Site) -> RemoteCallbacks { 44 | let mut remote_callbacks = RemoteCallbacks::new(); 45 | let ssh_key = site.ssh_key.clone(); 46 | remote_callbacks.credentials(move |_user, _user_from_url, cred| { 47 | debug!("Entered Git credential callback, cred={:?}", cred); 48 | if cred.contains(git2::CredentialType::USERNAME) { 49 | git2::Cred::username(&"git".to_string()) 50 | } else { 51 | let path = Path::new(&ssh_key); 52 | git2::Cred::ssh_key(&"git".to_string(), None, &path, None) 53 | } 54 | }); 55 | remote_callbacks.push_update_reference(|reference, status_option| { 56 | match status_option { 57 | Some(status) => error!( 58 | "Failed to update remote ref {} message={:?}", 59 | reference, status 60 | ), 61 | _ => info!("Updated remote ref {}", reference), 62 | }; 63 | Ok(()) 64 | }); 65 | remote_callbacks.update_tips(|reference, oid1, oid2| { 66 | debug!( 67 | "Updated tips, ref={} oid1={} oid2={}", 68 | reference, oid1, oid2 69 | ); 70 | true 71 | }); 72 | remote_callbacks 73 | } 74 | 75 | #[cfg_attr(test, mocked)] 76 | trait RepositoryExt { 77 | fn add_remotes(&mut self, pr_handle: &PrHandle) -> Result<(), GitError>; 78 | fn fetch_github_remote(&self, pr_handle: &PrHandle) -> Result<(), GitError>; 79 | fn create_ref_for_pr(&self, pr_handle: &PrHandle) -> Result<(), GitError>; 80 | fn push_pr_ref(&self, pr_handle: &PrHandle) -> Result<(), GitError>; 81 | fn delete_pr_ref(&self, pr_handle: &PrHandle) -> Result<(), GitError>; 82 | } 83 | 84 | #[derive(Debug, Eq, PartialEq)] 85 | pub struct PrHandle { 86 | base_full_name: String, 87 | head_full_name: String, 88 | github_remote: String, 89 | gitlab_remote: String, 90 | gitref: String, 91 | github_clone_url: String, 92 | pr_number: i64, 93 | } 94 | 95 | impl PrHandle { 96 | fn new(pr: &github::PullRequest) -> Result { 97 | let pr_handle = PrHandle { 98 | gitref: pr 99 | .pull_request 100 | .as_ref()? 101 | .head 102 | .as_ref()? 103 | .ref_key 104 | .as_ref()? 105 | .clone(), 106 | pr_number: pr.pull_request.as_ref()?.number?, 107 | github_clone_url: pr 108 | .pull_request 109 | .as_ref()? 110 | .head 111 | .as_ref()? 112 | .repo 113 | .as_ref()? 114 | .ssh_url 115 | .as_ref()? 116 | .clone(), 117 | github_remote: format!("github-{}", pr.pull_request.as_ref()?.number?,), 118 | gitlab_remote: "gitlab".to_string(), 119 | base_full_name: pr 120 | .pull_request 121 | .as_ref()? 122 | .base 123 | .as_ref()? 124 | .repo 125 | .as_ref()? 126 | .full_name 127 | .as_ref()? 128 | .clone(), 129 | head_full_name: pr 130 | .pull_request 131 | .as_ref()? 132 | .head 133 | .as_ref()? 134 | .repo 135 | .as_ref()? 136 | .full_name 137 | .as_ref()? 138 | .clone(), 139 | }; 140 | Ok(pr_handle) 141 | } 142 | } 143 | 144 | impl RepositoryExt for Repository { 145 | fn add_remotes(&mut self, pr_handle: &PrHandle) -> Result<(), GitError> { 146 | let github_refspec = format!("+refs/heads/*:refs/remotes/{}/*", pr_handle.github_remote); 147 | self.remote_add_fetch(&pr_handle.github_remote, &github_refspec)?; 148 | self.remote_set_url(&pr_handle.github_remote, &pr_handle.github_clone_url)?; 149 | let hostname = match config::CONFIG.gitlab.hostname.as_ref() { 150 | Some(hostname) => hostname.clone(), 151 | _ => "gitlab.com".to_string(), 152 | }; 153 | let gitlab_url = format!( 154 | "git@{}:{}.git", 155 | hostname, 156 | get_gitlab_repo_name(&pr_handle.base_full_name) 157 | ); 158 | let gitlab_refspec = "refs/heads/master:refs/heads/master".to_string(); 159 | self.remote_add_push(&pr_handle.gitlab_remote, &gitlab_refspec)?; 160 | self.remote_set_url(&pr_handle.gitlab_remote, &gitlab_url)?; 161 | Ok(()) 162 | } 163 | 164 | fn fetch_github_remote(&self, pr_handle: &PrHandle) -> Result<(), GitError> { 165 | info!( 166 | "Fetching remote={} ref={}", 167 | pr_handle.github_remote, pr_handle.gitref 168 | ); 169 | let mut remote = self.find_remote(&pr_handle.github_remote)?; 170 | 171 | let mut fetch_options = FetchOptions::new(); 172 | fetch_options.remote_callbacks(get_remote_callbacks(&config::CONFIG.github)); 173 | 174 | remote.fetch(&[&pr_handle.gitref], Some(&mut fetch_options), None)?; 175 | 176 | info!("Successfully fetched remote"); 177 | Ok(()) 178 | } 179 | 180 | fn create_ref_for_pr(&self, pr_handle: &PrHandle) -> Result<(), GitError> { 181 | let github_ref = format!( 182 | "refs/remotes/{}/{}", 183 | pr_handle.github_remote, pr_handle.gitref 184 | ); 185 | let gitlab_ref = format!( 186 | "refs/heads/pr-{}/{}/{}", 187 | pr_handle.pr_number, pr_handle.head_full_name, pr_handle.gitref 188 | ); 189 | let id = self.refname_to_id(&github_ref)?; 190 | debug!("Creating ref {} from {}, id={}", gitlab_ref, github_ref, id); 191 | self.reference(&gitlab_ref, id, true, "new ref")?; 192 | Ok(()) 193 | } 194 | 195 | fn push_pr_ref(&self, pr_handle: &PrHandle) -> Result<(), GitError> { 196 | info!( 197 | "Pushing PR remote={} ref={} number={} base_full_name={}", 198 | pr_handle.gitlab_remote, 199 | pr_handle.gitref, 200 | pr_handle.pr_number, 201 | pr_handle.base_full_name 202 | ); 203 | let mut gitremote = self.find_remote(&pr_handle.gitlab_remote)?; 204 | let mut push_options = PushOptions::new(); 205 | push_options.remote_callbacks(get_remote_callbacks(&config::CONFIG.gitlab)); 206 | 207 | let refspec = format!( 208 | "+refs/heads/pr-{}/{}/{}:refs/heads/pr-{}/{}/{}", 209 | pr_handle.pr_number, 210 | pr_handle.head_full_name, 211 | pr_handle.gitref, 212 | pr_handle.pr_number, 213 | pr_handle.head_full_name, 214 | pr_handle.gitref 215 | ); 216 | gitremote.push(&[&refspec], Some(&mut push_options))?; 217 | 218 | info!("Successfully pushed"); 219 | Ok(()) 220 | } 221 | 222 | fn delete_pr_ref(&self, pr_handle: &PrHandle) -> Result<(), GitError> { 223 | info!( 224 | "Deleting PR remote={} ref={} number={} base_full_name={}", 225 | pr_handle.gitlab_remote, 226 | pr_handle.gitref, 227 | pr_handle.pr_number, 228 | pr_handle.base_full_name 229 | ); 230 | let mut gitremote = self.find_remote(&pr_handle.gitlab_remote)?; 231 | let mut push_options = PushOptions::new(); 232 | push_options.remote_callbacks(get_remote_callbacks(&config::CONFIG.gitlab)); 233 | 234 | let refspec = format!( 235 | ":refs/heads/pr-{}/{}/{}", 236 | pr_handle.pr_number, pr_handle.head_full_name, pr_handle.gitref, 237 | ); 238 | gitremote.push(&[&refspec], Some(&mut push_options))?; 239 | 240 | info!("Successfully pushed"); 241 | Ok(()) 242 | } 243 | } 244 | 245 | fn clone_repo(url: &str) -> Result { 246 | // Setup fetch options 247 | let mut fetch_options = FetchOptions::new(); 248 | fetch_options.remote_callbacks(get_remote_callbacks(&config::CONFIG.github)); 249 | 250 | // Initialize & clone repo 251 | let mut builder = RepoBuilder::new(); 252 | builder.fetch_options(fetch_options); 253 | let dir = tempdir()?; 254 | match builder.clone(url, dir.as_ref()) { 255 | Ok(repo) => { 256 | info!( 257 | "Cloned new repo {} into {}", 258 | url, 259 | dir.as_ref().to_str().unwrap() 260 | ); 261 | 262 | Ok(RepoData { repo, dir }) 263 | } 264 | Err(err) => { 265 | let msg = format!("Error cloning repo: {:?}", err); 266 | error!("{}", &msg); 267 | Err(GitError { message: msg }) 268 | } 269 | } 270 | } 271 | 272 | fn handle_pr_closed_with_repo( 273 | repo: &mut dyn RepositoryExt, 274 | pr: &github::PullRequest, 275 | ) -> Result { 276 | let pr_handle = PrHandle::new(pr)?; 277 | 278 | info!("pr_handle={:#?}", pr_handle); 279 | 280 | repo.add_remotes(&pr_handle)?; 281 | repo.delete_pr_ref(&pr_handle)?; 282 | 283 | Ok(String::from("deleted :D")) 284 | } 285 | 286 | fn handle_pr_closed(pr: &github::PullRequest) -> Result { 287 | info!("Handling closed PR"); 288 | let url = pr.repository.as_ref()?.ssh_url.as_ref()?; 289 | let mut repos = REPOS.lock(); 290 | let repo_data = repos 291 | .as_mut() 292 | .unwrap() 293 | .entry(url.clone()) 294 | .or_insert(clone_repo(url)?); 295 | 296 | handle_pr_closed_with_repo(&mut repo_data.repo, pr) 297 | } 298 | 299 | fn handle_pr_updated(pr: &github::PullRequest) -> Result { 300 | info!("Handling open PR"); 301 | let url = pr.repository.as_ref()?.ssh_url.as_ref()?; 302 | let mut repos = REPOS.lock(); 303 | let repo_data = repos 304 | .as_mut() 305 | .unwrap() 306 | .entry(url.clone()) 307 | .or_insert(clone_repo(url)?); 308 | 309 | handle_pr_updated_with_repo(&mut repo_data.repo, pr) 310 | } 311 | 312 | fn handle_pr_updated_with_repo( 313 | repo: &mut dyn RepositoryExt, 314 | pr: &github::PullRequest, 315 | ) -> Result { 316 | info!("handle_pr_updated_with_repo"); 317 | let pr_handle = PrHandle::new(pr)?; 318 | 319 | info!("pr_handle={:#?}", pr_handle); 320 | 321 | repo.add_remotes(&pr_handle)?; 322 | repo.fetch_github_remote(&pr_handle)?; 323 | repo.create_ref_for_pr(&pr_handle)?; 324 | repo.push_pr_ref(&pr_handle)?; 325 | 326 | Ok(String::from(":)")) 327 | } 328 | 329 | impl github::PullRequest { 330 | fn is_fork(&self) -> Result { 331 | Ok(self 332 | .pull_request 333 | .as_ref()? 334 | .head 335 | .as_ref()? 336 | .repo 337 | .as_ref()? 338 | .fork?) 339 | } 340 | } 341 | 342 | fn handle_pr(pr: github::PullRequest) -> Result<(), RequestErrorResult> { 343 | if pr.is_fork()? { 344 | info!("PR is a fork"); 345 | let result = match pr.action.as_ref()?.as_ref() { 346 | "closed" => handle_pr_closed(&pr), 347 | _ => handle_pr_updated(&pr), 348 | }; 349 | match result { 350 | Ok(ok) => info!("Handled PR: {}", ok), 351 | Err(err) => error!("Caught error handling PR: {:?}", err), 352 | } 353 | } else { 354 | info!("Skipping PR because it's not a fork, cya 👋"); 355 | } 356 | Ok(()) 357 | } 358 | 359 | fn write_issue_comment( 360 | client: &reqwest::Client, 361 | ic: &github::IssueComment, 362 | body: &str, 363 | ) -> Result<(), GitError> { 364 | let repo_full_name = ic.repository.as_ref()?.full_name.as_ref()?; 365 | let repo_full_name_parts: Vec = repo_full_name 366 | .split('/') 367 | .map(std::string::ToString::to_string) 368 | .collect(); 369 | if repo_full_name_parts.len() != 2 { 370 | return Err(GitError { 371 | message: format!("Invalid repo name {}", repo_full_name), 372 | }); 373 | } 374 | github_client::create_issue_comment( 375 | client, 376 | &repo_full_name_parts[0], 377 | &repo_full_name_parts[1], 378 | ic.issue.as_ref()?.number?, 379 | body, 380 | ) 381 | } 382 | 383 | fn get_sha(client: &reqwest::Client, ic: &github::IssueComment) -> Result { 384 | let repo_full_name = ic.repository.as_ref()?.full_name.as_ref()?; 385 | let repo_full_name_parts: Vec = repo_full_name 386 | .split('/') 387 | .map(std::string::ToString::to_string) 388 | .collect(); 389 | if repo_full_name_parts.len() != 2 { 390 | return Err(GitError { 391 | message: format!("Invalid repo name {}", repo_full_name), 392 | }); 393 | } 394 | let pr = github_client::get_pull( 395 | client, 396 | &repo_full_name_parts[0], 397 | &repo_full_name_parts[1], 398 | ic.issue.as_ref()?.number?, 399 | )?; 400 | Ok(pr.head.as_ref()?.sha.as_ref()?.clone()) 401 | } 402 | 403 | impl github::IssueComment { 404 | fn is_from_pr(&self) -> Result { 405 | Ok(self.issue.as_ref()?.pull_request.is_some()) 406 | } 407 | } 408 | 409 | fn find_pipeline_id(client: &reqwest::Client, project: &str, sha: &str) -> Result { 410 | let mut result_len = 100; 411 | let mut page = 1; 412 | while result_len == 100 { 413 | let pipelines = gitlab_client::get_pipelines(client, project, page, 100)?; 414 | let pipeline = pipelines 415 | .iter() 416 | .filter(|p| p.sha.is_some() && p.id.is_some()) 417 | .find(|p| p.sha.as_ref().unwrap() == sha); 418 | if let Some(pipeline) = pipeline { 419 | return Ok(pipeline.id.unwrap()); 420 | } 421 | result_len = pipelines.len(); 422 | page += 1; 423 | } 424 | Err(GitError { 425 | message: format!( 426 | "Unable to find pipeline for project={} sha={}", 427 | project, sha 428 | ), 429 | }) 430 | } 431 | 432 | fn handle_retry_command( 433 | client: &reqwest::Client, 434 | ic: &github::IssueComment, 435 | ) -> Result<(), GitError> { 436 | let repo_full_name = ic.repository.as_ref()?.full_name.as_ref()?; 437 | let sha = get_sha(&client, ic)?; 438 | let project = get_gitlab_repo_name(&repo_full_name); 439 | info!("Got retry command for project={} sha={}", project, sha); 440 | let pipeline_id = find_pipeline_id(&client, &get_gitlab_repo_name(&project), &sha)?; 441 | gitlab_client::retry_pipeline(&client, &project, pipeline_id)?; 442 | 443 | let comment_body = format!( 444 | "Sent **retry** command for pipeline [**{}**]({}/pipelines/{}) on [**GitLab**]({}) 445 | 446 | Have a great day! 😄", 447 | pipeline_id, 448 | gitlab_client::make_ext_url(&project), 449 | pipeline_id, 450 | gitlab_client::make_ext_url(&project), 451 | ); 452 | 453 | write_issue_comment(&client, ic, &comment_body) 454 | } 455 | 456 | fn handle_pr_ic(ic: github::IssueComment) -> Result<(), GitError> { 457 | let client = reqwest::Client::new(); 458 | info!( 459 | "Issue comment received for issue number={} action={}", 460 | ic.issue.as_ref()?.number?, 461 | ic.action.as_ref()?, 462 | ); 463 | 464 | if ic.sender.as_ref()?.login.as_ref()? == &config::CONFIG.github.username { 465 | info!("Hey this is my comment :D Skipping"); 466 | return Ok(()); 467 | } 468 | 469 | let command_res = commands::parse_body( 470 | ic.comment.as_ref()?.body.as_ref()?, 471 | &*config::CONFIG.github.username, 472 | ); 473 | 474 | match command_res { 475 | Err(commands::CommandError::UnknownCommand) => { 476 | // Write a comment on the PR 477 | let comment_body = "Sorry, but I don't know what that command means. 478 | 479 | Thanks for asking 🥰" 480 | .to_string(); 481 | 482 | write_issue_comment(&client, &ic, &comment_body)?; 483 | Ok(()) 484 | } 485 | _ => { 486 | let command = command_res.unwrap(); 487 | 488 | if !config::command_enabled(&command.command) { 489 | warn!("Command {:#?} is not enabled.", command.command); 490 | Ok(()) 491 | } else { 492 | match command.command { 493 | commands::CommandAction::Retry => handle_retry_command(&client, &ic), 494 | } 495 | } 496 | } 497 | } 498 | } 499 | 500 | fn handle_ic(ic: github::IssueComment) { 501 | if ic.is_from_pr().unwrap() { 502 | match handle_pr_ic(ic) { 503 | Ok(()) => info!("Finished handling issue comment"), 504 | Err(_err) => info!("Ignoring issue comment because it's invalid"), 505 | } 506 | } 507 | } 508 | 509 | pub fn handle_event_body(event_type: &str, body: &str) -> Result { 510 | match event_type { 511 | "push" => { 512 | let push: github::Push = serde_json::from_str(body)?; 513 | info!("Push ref={}", push.ref_key.as_ref()?); 514 | Ok(String::from("Push received 😘")) 515 | } 516 | "pull_request" => { 517 | if config::feature_enabled(&config::Feature::ExternalPr) { 518 | let pr: github::PullRequest = serde_json::from_str(body)?; 519 | // check if pull request event trigger action is enabled in config file 520 | if config::action_enabled(pr.action.as_ref()?) { 521 | info!("PullRequest action={}", pr.action.as_ref()?); 522 | thread::spawn(move || handle_pr(pr)); 523 | } else { 524 | info!("Event trigger action not enabled. Skipping event."); 525 | } 526 | } else { 527 | info!("ExternalPr feature not enabled. Skipping event."); 528 | } 529 | Ok(String::from("Thanks buddy bro 😍")) 530 | } 531 | "issue_comment" => { 532 | if config::feature_enabled(&config::Feature::Commands) { 533 | let ic: github::IssueComment = serde_json::from_str(body)?; 534 | info!( 535 | "Issue comment action={} user={}", 536 | ic.action.as_ref()?, 537 | ic.issue.as_ref()?.user.as_ref()?.login.as_ref()? 538 | ); 539 | thread::spawn(move || handle_ic(ic)); 540 | } else { 541 | info!("Commands feature not enabled. Skipping event."); 542 | } 543 | Ok(String::from("Issue comment received 🥳")) 544 | } 545 | _ => Ok(format!( 546 | "Unhandled event_type={}, doing nothing 😀", 547 | event_type, 548 | )), 549 | } 550 | } 551 | 552 | #[cfg(test)] 553 | mod test { 554 | use super::*; 555 | use crate::testing::{read_testdata_to_string, run_test}; 556 | // use mockers::Scenario; 557 | #[test] 558 | fn open_pr() { 559 | run_test(|| { 560 | info!("open_pr test"); 561 | let pr: github::PullRequest = 562 | serde_json::from_str(&read_testdata_to_string("github_open_pull_request.json")) 563 | .unwrap(); 564 | assert_eq!(pr.is_fork().unwrap(), false); 565 | let _pr_handle = PrHandle::new(&pr).unwrap(); 566 | }); 567 | } 568 | 569 | #[test] 570 | fn reopen_pr() { 571 | run_test(|| { 572 | info!("reopen_pr test"); 573 | let pr: github::PullRequest = 574 | serde_json::from_str(&read_testdata_to_string("github_reopen_pull_request.json")) 575 | .unwrap(); 576 | assert_eq!(pr.is_fork().unwrap(), false); 577 | let _pr_handle = PrHandle::new(&pr).unwrap(); 578 | }); 579 | } 580 | 581 | #[test] 582 | fn open_pr_fork() { 583 | run_test(|| { 584 | info!("open_pr_fork test"); 585 | let pr: github::PullRequest = 586 | serde_json::from_str(&read_testdata_to_string("github_open_pr_forked.json")) 587 | .unwrap(); 588 | assert_eq!(pr.is_fork().unwrap(), true); 589 | let _pr_handle = PrHandle::new(&pr).unwrap(); 590 | }); 591 | } 592 | 593 | #[test] 594 | fn close_pr_fork() { 595 | run_test(|| { 596 | info!("close_pr_fork test"); 597 | let pr: github::PullRequest = 598 | serde_json::from_str(&read_testdata_to_string("github_close_pr_forked.json")) 599 | .unwrap(); 600 | let _pr_handle = PrHandle::new(&pr).unwrap(); 601 | }); 602 | } 603 | 604 | #[test] 605 | fn get_pr() { 606 | run_test(|| { 607 | info!("get_pr test"); 608 | let _pr: github::RepoPr = 609 | serde_json::from_str(&read_testdata_to_string("github_get_pr.json")).unwrap(); 610 | }); 611 | } 612 | 613 | #[test] 614 | fn created_issue_comment() { 615 | run_test(|| { 616 | info!("created_issue_comment test"); 617 | let _ic: github::IssueComment = serde_json::from_str(&read_testdata_to_string( 618 | "github_created_issue_comment.json", 619 | )) 620 | .unwrap(); 621 | }); 622 | } 623 | } 624 | -------------------------------------------------------------------------------- /src/testdata/github_get_pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347", 3 | "id": 1, 4 | "node_id": "MDExOlB1bGxSZXF1ZXN0MQ==", 5 | "html_url": "https://github.com/octocat/Hello-World/pull/1347", 6 | "diff_url": "https://github.com/octocat/Hello-World/pull/1347.diff", 7 | "patch_url": "https://github.com/octocat/Hello-World/pull/1347.patch", 8 | "issue_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347", 9 | "commits_url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/commits", 10 | "review_comments_url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/comments", 11 | "review_comment_url": "https://api.github.com/repos/octocat/Hello-World/pulls/comments{/number}", 12 | "comments_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments", 13 | "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e", 14 | "number": 1347, 15 | "state": "open", 16 | "locked": true, 17 | "title": "new-feature", 18 | "user": { 19 | "login": "octocat", 20 | "id": 1, 21 | "node_id": "MDQ6VXNlcjE=", 22 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 23 | "gravatar_id": "", 24 | "url": "https://api.github.com/users/octocat", 25 | "html_url": "https://github.com/octocat", 26 | "followers_url": "https://api.github.com/users/octocat/followers", 27 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 28 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 29 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 30 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 31 | "organizations_url": "https://api.github.com/users/octocat/orgs", 32 | "repos_url": "https://api.github.com/users/octocat/repos", 33 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 34 | "received_events_url": "https://api.github.com/users/octocat/received_events", 35 | "type": "User", 36 | "site_admin": false 37 | }, 38 | "body": "Please pull these awesome changes", 39 | "labels": [ 40 | { 41 | "id": 208045946, 42 | "node_id": "MDU6TGFiZWwyMDgwNDU5NDY=", 43 | "url": "https://api.github.com/repos/octocat/Hello-World/labels/bug", 44 | "name": "bug", 45 | "description": "Something isn't working", 46 | "color": "f29513", 47 | "default": true 48 | } 49 | ], 50 | "milestone": { 51 | "url": "https://api.github.com/repos/octocat/Hello-World/milestones/1", 52 | "html_url": "https://github.com/octocat/Hello-World/milestones/v1.0", 53 | "labels_url": "https://api.github.com/repos/octocat/Hello-World/milestones/1/labels", 54 | "id": 1002604, 55 | "node_id": "MDk6TWlsZXN0b25lMTAwMjYwNA==", 56 | "number": 1, 57 | "state": "open", 58 | "title": "v1.0", 59 | "description": "Tracking milestone for version 1.0", 60 | "creator": { 61 | "login": "octocat", 62 | "id": 1, 63 | "node_id": "MDQ6VXNlcjE=", 64 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 65 | "gravatar_id": "", 66 | "url": "https://api.github.com/users/octocat", 67 | "html_url": "https://github.com/octocat", 68 | "followers_url": "https://api.github.com/users/octocat/followers", 69 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 70 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 71 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 72 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 73 | "organizations_url": "https://api.github.com/users/octocat/orgs", 74 | "repos_url": "https://api.github.com/users/octocat/repos", 75 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 76 | "received_events_url": "https://api.github.com/users/octocat/received_events", 77 | "type": "User", 78 | "site_admin": false 79 | }, 80 | "open_issues": 4, 81 | "closed_issues": 8, 82 | "created_at": "2011-04-10T20:09:31Z", 83 | "updated_at": "2014-03-03T18:58:10Z", 84 | "closed_at": "2013-02-12T13:22:01Z", 85 | "due_on": "2012-10-09T23:39:01Z" 86 | }, 87 | "active_lock_reason": "too heated", 88 | "created_at": "2011-01-26T19:01:12Z", 89 | "updated_at": "2011-01-26T19:01:12Z", 90 | "closed_at": "2011-01-26T19:01:12Z", 91 | "merged_at": "2011-01-26T19:01:12Z", 92 | "merge_commit_sha": "e5bd3914e2e596debea16f433f57875b5b90bcd6", 93 | "assignee": { 94 | "login": "octocat", 95 | "id": 1, 96 | "node_id": "MDQ6VXNlcjE=", 97 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 98 | "gravatar_id": "", 99 | "url": "https://api.github.com/users/octocat", 100 | "html_url": "https://github.com/octocat", 101 | "followers_url": "https://api.github.com/users/octocat/followers", 102 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 103 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 104 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 105 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 106 | "organizations_url": "https://api.github.com/users/octocat/orgs", 107 | "repos_url": "https://api.github.com/users/octocat/repos", 108 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 109 | "received_events_url": "https://api.github.com/users/octocat/received_events", 110 | "type": "User", 111 | "site_admin": false 112 | }, 113 | "assignees": [ 114 | { 115 | "login": "octocat", 116 | "id": 1, 117 | "node_id": "MDQ6VXNlcjE=", 118 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 119 | "gravatar_id": "", 120 | "url": "https://api.github.com/users/octocat", 121 | "html_url": "https://github.com/octocat", 122 | "followers_url": "https://api.github.com/users/octocat/followers", 123 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 124 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 125 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 126 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 127 | "organizations_url": "https://api.github.com/users/octocat/orgs", 128 | "repos_url": "https://api.github.com/users/octocat/repos", 129 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 130 | "received_events_url": "https://api.github.com/users/octocat/received_events", 131 | "type": "User", 132 | "site_admin": false 133 | }, 134 | { 135 | "login": "hubot", 136 | "id": 1, 137 | "node_id": "MDQ6VXNlcjE=", 138 | "avatar_url": "https://github.com/images/error/hubot_happy.gif", 139 | "gravatar_id": "", 140 | "url": "https://api.github.com/users/hubot", 141 | "html_url": "https://github.com/hubot", 142 | "followers_url": "https://api.github.com/users/hubot/followers", 143 | "following_url": "https://api.github.com/users/hubot/following{/other_user}", 144 | "gists_url": "https://api.github.com/users/hubot/gists{/gist_id}", 145 | "starred_url": "https://api.github.com/users/hubot/starred{/owner}{/repo}", 146 | "subscriptions_url": "https://api.github.com/users/hubot/subscriptions", 147 | "organizations_url": "https://api.github.com/users/hubot/orgs", 148 | "repos_url": "https://api.github.com/users/hubot/repos", 149 | "events_url": "https://api.github.com/users/hubot/events{/privacy}", 150 | "received_events_url": "https://api.github.com/users/hubot/received_events", 151 | "type": "User", 152 | "site_admin": true 153 | } 154 | ], 155 | "requested_reviewers": [ 156 | { 157 | "login": "other_user", 158 | "id": 1, 159 | "node_id": "MDQ6VXNlcjE=", 160 | "avatar_url": "https://github.com/images/error/other_user_happy.gif", 161 | "gravatar_id": "", 162 | "url": "https://api.github.com/users/other_user", 163 | "html_url": "https://github.com/other_user", 164 | "followers_url": "https://api.github.com/users/other_user/followers", 165 | "following_url": "https://api.github.com/users/other_user/following{/other_user}", 166 | "gists_url": "https://api.github.com/users/other_user/gists{/gist_id}", 167 | "starred_url": "https://api.github.com/users/other_user/starred{/owner}{/repo}", 168 | "subscriptions_url": "https://api.github.com/users/other_user/subscriptions", 169 | "organizations_url": "https://api.github.com/users/other_user/orgs", 170 | "repos_url": "https://api.github.com/users/other_user/repos", 171 | "events_url": "https://api.github.com/users/other_user/events{/privacy}", 172 | "received_events_url": "https://api.github.com/users/other_user/received_events", 173 | "type": "User", 174 | "site_admin": false 175 | } 176 | ], 177 | "requested_teams": [ 178 | { 179 | "id": 1, 180 | "node_id": "MDQ6VGVhbTE=", 181 | "url": "https://api.github.com/teams/1", 182 | "name": "Justice League", 183 | "slug": "justice-league", 184 | "description": "A great team.", 185 | "privacy": "closed", 186 | "permission": "admin", 187 | "members_url": "https://api.github.com/teams/1/members{/member}", 188 | "repositories_url": "https://api.github.com/teams/1/repos", 189 | "parent": null 190 | } 191 | ], 192 | "head": { 193 | "label": "octocat:new-topic", 194 | "ref": "new-topic", 195 | "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", 196 | "user": { 197 | "login": "octocat", 198 | "id": 1, 199 | "node_id": "MDQ6VXNlcjE=", 200 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 201 | "gravatar_id": "", 202 | "url": "https://api.github.com/users/octocat", 203 | "html_url": "https://github.com/octocat", 204 | "followers_url": "https://api.github.com/users/octocat/followers", 205 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 206 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 207 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 208 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 209 | "organizations_url": "https://api.github.com/users/octocat/orgs", 210 | "repos_url": "https://api.github.com/users/octocat/repos", 211 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 212 | "received_events_url": "https://api.github.com/users/octocat/received_events", 213 | "type": "User", 214 | "site_admin": false 215 | }, 216 | "repo": { 217 | "id": 1296269, 218 | "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", 219 | "name": "Hello-World", 220 | "full_name": "octocat/Hello-World", 221 | "owner": { 222 | "login": "octocat", 223 | "id": 1, 224 | "node_id": "MDQ6VXNlcjE=", 225 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 226 | "gravatar_id": "", 227 | "url": "https://api.github.com/users/octocat", 228 | "html_url": "https://github.com/octocat", 229 | "followers_url": "https://api.github.com/users/octocat/followers", 230 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 231 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 232 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 233 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 234 | "organizations_url": "https://api.github.com/users/octocat/orgs", 235 | "repos_url": "https://api.github.com/users/octocat/repos", 236 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 237 | "received_events_url": "https://api.github.com/users/octocat/received_events", 238 | "type": "User", 239 | "site_admin": false 240 | }, 241 | "private": false, 242 | "html_url": "https://github.com/octocat/Hello-World", 243 | "description": "This your first repo!", 244 | "fork": true, 245 | "url": "https://api.github.com/repos/octocat/Hello-World", 246 | "archive_url": "http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", 247 | "assignees_url": "http://api.github.com/repos/octocat/Hello-World/assignees{/user}", 248 | "blobs_url": "http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", 249 | "branches_url": "http://api.github.com/repos/octocat/Hello-World/branches{/branch}", 250 | "collaborators_url": "http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", 251 | "comments_url": "http://api.github.com/repos/octocat/Hello-World/comments{/number}", 252 | "commits_url": "http://api.github.com/repos/octocat/Hello-World/commits{/sha}", 253 | "compare_url": "http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", 254 | "contents_url": "http://api.github.com/repos/octocat/Hello-World/contents/{+path}", 255 | "contributors_url": "http://api.github.com/repos/octocat/Hello-World/contributors", 256 | "deployments_url": "http://api.github.com/repos/octocat/Hello-World/deployments", 257 | "downloads_url": "http://api.github.com/repos/octocat/Hello-World/downloads", 258 | "events_url": "http://api.github.com/repos/octocat/Hello-World/events", 259 | "forks_url": "http://api.github.com/repos/octocat/Hello-World/forks", 260 | "git_commits_url": "http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", 261 | "git_refs_url": "http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", 262 | "git_tags_url": "http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", 263 | "git_url": "git:github.com/octocat/Hello-World.git", 264 | "issue_comment_url": "http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", 265 | "issue_events_url": "http://api.github.com/repos/octocat/Hello-World/issues/events{/number}", 266 | "issues_url": "http://api.github.com/repos/octocat/Hello-World/issues{/number}", 267 | "keys_url": "http://api.github.com/repos/octocat/Hello-World/keys{/key_id}", 268 | "labels_url": "http://api.github.com/repos/octocat/Hello-World/labels{/name}", 269 | "languages_url": "http://api.github.com/repos/octocat/Hello-World/languages", 270 | "merges_url": "http://api.github.com/repos/octocat/Hello-World/merges", 271 | "milestones_url": "http://api.github.com/repos/octocat/Hello-World/milestones{/number}", 272 | "notifications_url": "http://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", 273 | "pulls_url": "http://api.github.com/repos/octocat/Hello-World/pulls{/number}", 274 | "releases_url": "http://api.github.com/repos/octocat/Hello-World/releases{/id}", 275 | "ssh_url": "git@github.com:octocat/Hello-World.git", 276 | "stargazers_url": "http://api.github.com/repos/octocat/Hello-World/stargazers", 277 | "statuses_url": "http://api.github.com/repos/octocat/Hello-World/statuses/{sha}", 278 | "subscribers_url": "http://api.github.com/repos/octocat/Hello-World/subscribers", 279 | "subscription_url": "http://api.github.com/repos/octocat/Hello-World/subscription", 280 | "tags_url": "http://api.github.com/repos/octocat/Hello-World/tags", 281 | "teams_url": "http://api.github.com/repos/octocat/Hello-World/teams", 282 | "trees_url": "http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", 283 | "clone_url": "https://github.com/octocat/Hello-World.git", 284 | "mirror_url": "git:git.example.com/octocat/Hello-World", 285 | "hooks_url": "http://api.github.com/repos/octocat/Hello-World/hooks", 286 | "svn_url": "https://svn.github.com/octocat/Hello-World", 287 | "homepage": "https://github.com", 288 | "language": null, 289 | "forks_count": 9, 290 | "stargazers_count": 80, 291 | "watchers_count": 80, 292 | "size": 108, 293 | "default_branch": "master", 294 | "open_issues_count": 0, 295 | "topics": [ 296 | "octocat", 297 | "atom", 298 | "electron", 299 | "API" 300 | ], 301 | "has_issues": true, 302 | "has_projects": true, 303 | "has_wiki": true, 304 | "has_pages": false, 305 | "has_downloads": true, 306 | "archived": false, 307 | "pushed_at": "2011-01-26T19:06:43Z", 308 | "created_at": "2011-01-26T19:01:12Z", 309 | "updated_at": "2011-01-26T19:14:43Z", 310 | "permissions": { 311 | "admin": false, 312 | "push": false, 313 | "pull": true 314 | }, 315 | "allow_rebase_merge": true, 316 | "allow_squash_merge": true, 317 | "allow_merge_commit": true, 318 | "subscribers_count": 42, 319 | "network_count": 0 320 | } 321 | }, 322 | "base": { 323 | "label": "octocat:master", 324 | "ref": "master", 325 | "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", 326 | "user": { 327 | "login": "octocat", 328 | "id": 1, 329 | "node_id": "MDQ6VXNlcjE=", 330 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 331 | "gravatar_id": "", 332 | "url": "https://api.github.com/users/octocat", 333 | "html_url": "https://github.com/octocat", 334 | "followers_url": "https://api.github.com/users/octocat/followers", 335 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 336 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 337 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 338 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 339 | "organizations_url": "https://api.github.com/users/octocat/orgs", 340 | "repos_url": "https://api.github.com/users/octocat/repos", 341 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 342 | "received_events_url": "https://api.github.com/users/octocat/received_events", 343 | "type": "User", 344 | "site_admin": false 345 | }, 346 | "repo": { 347 | "id": 1296269, 348 | "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", 349 | "name": "Hello-World", 350 | "full_name": "octocat/Hello-World", 351 | "owner": { 352 | "login": "octocat", 353 | "id": 1, 354 | "node_id": "MDQ6VXNlcjE=", 355 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 356 | "gravatar_id": "", 357 | "url": "https://api.github.com/users/octocat", 358 | "html_url": "https://github.com/octocat", 359 | "followers_url": "https://api.github.com/users/octocat/followers", 360 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 361 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 362 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 363 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 364 | "organizations_url": "https://api.github.com/users/octocat/orgs", 365 | "repos_url": "https://api.github.com/users/octocat/repos", 366 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 367 | "received_events_url": "https://api.github.com/users/octocat/received_events", 368 | "type": "User", 369 | "site_admin": false 370 | }, 371 | "private": false, 372 | "html_url": "https://github.com/octocat/Hello-World", 373 | "description": "This your first repo!", 374 | "fork": true, 375 | "url": "https://api.github.com/repos/octocat/Hello-World", 376 | "archive_url": "http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", 377 | "assignees_url": "http://api.github.com/repos/octocat/Hello-World/assignees{/user}", 378 | "blobs_url": "http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", 379 | "branches_url": "http://api.github.com/repos/octocat/Hello-World/branches{/branch}", 380 | "collaborators_url": "http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", 381 | "comments_url": "http://api.github.com/repos/octocat/Hello-World/comments{/number}", 382 | "commits_url": "http://api.github.com/repos/octocat/Hello-World/commits{/sha}", 383 | "compare_url": "http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", 384 | "contents_url": "http://api.github.com/repos/octocat/Hello-World/contents/{+path}", 385 | "contributors_url": "http://api.github.com/repos/octocat/Hello-World/contributors", 386 | "deployments_url": "http://api.github.com/repos/octocat/Hello-World/deployments", 387 | "downloads_url": "http://api.github.com/repos/octocat/Hello-World/downloads", 388 | "events_url": "http://api.github.com/repos/octocat/Hello-World/events", 389 | "forks_url": "http://api.github.com/repos/octocat/Hello-World/forks", 390 | "git_commits_url": "http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", 391 | "git_refs_url": "http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", 392 | "git_tags_url": "http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", 393 | "git_url": "git:github.com/octocat/Hello-World.git", 394 | "issue_comment_url": "http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", 395 | "issue_events_url": "http://api.github.com/repos/octocat/Hello-World/issues/events{/number}", 396 | "issues_url": "http://api.github.com/repos/octocat/Hello-World/issues{/number}", 397 | "keys_url": "http://api.github.com/repos/octocat/Hello-World/keys{/key_id}", 398 | "labels_url": "http://api.github.com/repos/octocat/Hello-World/labels{/name}", 399 | "languages_url": "http://api.github.com/repos/octocat/Hello-World/languages", 400 | "merges_url": "http://api.github.com/repos/octocat/Hello-World/merges", 401 | "milestones_url": "http://api.github.com/repos/octocat/Hello-World/milestones{/number}", 402 | "notifications_url": "http://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", 403 | "pulls_url": "http://api.github.com/repos/octocat/Hello-World/pulls{/number}", 404 | "releases_url": "http://api.github.com/repos/octocat/Hello-World/releases{/id}", 405 | "ssh_url": "git@github.com:octocat/Hello-World.git", 406 | "stargazers_url": "http://api.github.com/repos/octocat/Hello-World/stargazers", 407 | "statuses_url": "http://api.github.com/repos/octocat/Hello-World/statuses/{sha}", 408 | "subscribers_url": "http://api.github.com/repos/octocat/Hello-World/subscribers", 409 | "subscription_url": "http://api.github.com/repos/octocat/Hello-World/subscription", 410 | "tags_url": "http://api.github.com/repos/octocat/Hello-World/tags", 411 | "teams_url": "http://api.github.com/repos/octocat/Hello-World/teams", 412 | "trees_url": "http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", 413 | "clone_url": "https://github.com/octocat/Hello-World.git", 414 | "mirror_url": "git:git.example.com/octocat/Hello-World", 415 | "hooks_url": "http://api.github.com/repos/octocat/Hello-World/hooks", 416 | "svn_url": "https://svn.github.com/octocat/Hello-World", 417 | "homepage": "https://github.com", 418 | "language": null, 419 | "forks_count": 9, 420 | "stargazers_count": 80, 421 | "watchers_count": 80, 422 | "size": 108, 423 | "default_branch": "master", 424 | "open_issues_count": 0, 425 | "topics": [ 426 | "octocat", 427 | "atom", 428 | "electron", 429 | "API" 430 | ], 431 | "has_issues": true, 432 | "has_projects": true, 433 | "has_wiki": true, 434 | "has_pages": false, 435 | "has_downloads": true, 436 | "archived": false, 437 | "pushed_at": "2011-01-26T19:06:43Z", 438 | "created_at": "2011-01-26T19:01:12Z", 439 | "updated_at": "2011-01-26T19:14:43Z", 440 | "permissions": { 441 | "admin": false, 442 | "push": false, 443 | "pull": true 444 | }, 445 | "allow_rebase_merge": true, 446 | "allow_squash_merge": true, 447 | "allow_merge_commit": true, 448 | "subscribers_count": 42, 449 | "network_count": 0 450 | } 451 | }, 452 | "_links": { 453 | "self": { 454 | "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347" 455 | }, 456 | "html": { 457 | "href": "https://github.com/octocat/Hello-World/pull/1347" 458 | }, 459 | "issue": { 460 | "href": "https://api.github.com/repos/octocat/Hello-World/issues/1347" 461 | }, 462 | "comments": { 463 | "href": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments" 464 | }, 465 | "review_comments": { 466 | "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/comments" 467 | }, 468 | "review_comment": { 469 | "href": "https://api.github.com/repos/octocat/Hello-World/pulls/comments{/number}" 470 | }, 471 | "commits": { 472 | "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/commits" 473 | }, 474 | "statuses": { 475 | "href": "https://api.github.com/repos/octocat/Hello-World/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e" 476 | } 477 | }, 478 | "author_association": "OWNER", 479 | "draft": false, 480 | "merged": false, 481 | "mergeable": true, 482 | "rebaseable": true, 483 | "mergeable_state": "clean", 484 | "merged_by": { 485 | "login": "octocat", 486 | "id": 1, 487 | "node_id": "MDQ6VXNlcjE=", 488 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 489 | "gravatar_id": "", 490 | "url": "https://api.github.com/users/octocat", 491 | "html_url": "https://github.com/octocat", 492 | "followers_url": "https://api.github.com/users/octocat/followers", 493 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 494 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 495 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 496 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 497 | "organizations_url": "https://api.github.com/users/octocat/orgs", 498 | "repos_url": "https://api.github.com/users/octocat/repos", 499 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 500 | "received_events_url": "https://api.github.com/users/octocat/received_events", 501 | "type": "User", 502 | "site_admin": false 503 | }, 504 | "comments": 10, 505 | "review_comments": 0, 506 | "maintainer_can_modify": true, 507 | "commits": 3, 508 | "additions": 100, 509 | "deletions": 3, 510 | "changed_files": 5 511 | } 512 | -------------------------------------------------------------------------------- /src/testdata/github_open_pr_forked.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "number": 5, 4 | "pull_request": { 5 | "url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/5", 6 | "id": 257701028, 7 | "node_id": "MDExOlB1bGxSZXF1ZXN0MjU3NzAxMDI4", 8 | "html_url": "https://github.com/brndnmtthws/labhub-test/pull/5", 9 | "diff_url": "https://github.com/brndnmtthws/labhub-test/pull/5.diff", 10 | "patch_url": "https://github.com/brndnmtthws/labhub-test/pull/5.patch", 11 | "issue_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/5", 12 | "number": 5, 13 | "state": "open", 14 | "locked": false, 15 | "title": "Update README.md", 16 | "user": { 17 | "login": "conky-ci", 18 | "id": 39227759, 19 | "node_id": "MDQ6VXNlcjM5MjI3NzU5", 20 | "avatar_url": "https://avatars3.githubusercontent.com/u/39227759?v=4", 21 | "gravatar_id": "", 22 | "url": "https://api.github.com/users/conky-ci", 23 | "html_url": "https://github.com/conky-ci", 24 | "followers_url": "https://api.github.com/users/conky-ci/followers", 25 | "following_url": "https://api.github.com/users/conky-ci/following{/other_user}", 26 | "gists_url": "https://api.github.com/users/conky-ci/gists{/gist_id}", 27 | "starred_url": "https://api.github.com/users/conky-ci/starred{/owner}{/repo}", 28 | "subscriptions_url": "https://api.github.com/users/conky-ci/subscriptions", 29 | "organizations_url": "https://api.github.com/users/conky-ci/orgs", 30 | "repos_url": "https://api.github.com/users/conky-ci/repos", 31 | "events_url": "https://api.github.com/users/conky-ci/events{/privacy}", 32 | "received_events_url": "https://api.github.com/users/conky-ci/received_events", 33 | "type": "User", 34 | "site_admin": false 35 | }, 36 | "body": "", 37 | "created_at": "2019-03-02T23:57:14Z", 38 | "updated_at": "2019-03-02T23:57:14Z", 39 | "closed_at": null, 40 | "merged_at": null, 41 | "merge_commit_sha": null, 42 | "assignee": null, 43 | "assignees": [], 44 | "requested_reviewers": [], 45 | "requested_teams": [], 46 | "labels": [], 47 | "milestone": null, 48 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/5/commits", 49 | "review_comments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/5/comments", 50 | "review_comment_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/comments{/number}", 51 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/5/comments", 52 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub-test/statuses/2902de5e1c2993c0abd6a12bb126c69512ad3741", 53 | "head": { 54 | "label": "conky-ci:conky-ci-patch-1", 55 | "ref": "conky-ci-patch-1", 56 | "sha": "2902de5e1c2993c0abd6a12bb126c69512ad3741", 57 | "user": { 58 | "login": "conky-ci", 59 | "id": 39227759, 60 | "node_id": "MDQ6VXNlcjM5MjI3NzU5", 61 | "avatar_url": "https://avatars3.githubusercontent.com/u/39227759?v=4", 62 | "gravatar_id": "", 63 | "url": "https://api.github.com/users/conky-ci", 64 | "html_url": "https://github.com/conky-ci", 65 | "followers_url": "https://api.github.com/users/conky-ci/followers", 66 | "following_url": "https://api.github.com/users/conky-ci/following{/other_user}", 67 | "gists_url": "https://api.github.com/users/conky-ci/gists{/gist_id}", 68 | "starred_url": "https://api.github.com/users/conky-ci/starred{/owner}{/repo}", 69 | "subscriptions_url": "https://api.github.com/users/conky-ci/subscriptions", 70 | "organizations_url": "https://api.github.com/users/conky-ci/orgs", 71 | "repos_url": "https://api.github.com/users/conky-ci/repos", 72 | "events_url": "https://api.github.com/users/conky-ci/events{/privacy}", 73 | "received_events_url": "https://api.github.com/users/conky-ci/received_events", 74 | "type": "User", 75 | "site_admin": false 76 | }, 77 | "repo": { 78 | "id": 173511786, 79 | "node_id": "MDEwOlJlcG9zaXRvcnkxNzM1MTE3ODY=", 80 | "name": "labhub-test", 81 | "full_name": "conky-ci/labhub-test", 82 | "private": false, 83 | "owner": { 84 | "login": "conky-ci", 85 | "id": 39227759, 86 | "node_id": "MDQ6VXNlcjM5MjI3NzU5", 87 | "avatar_url": "https://avatars3.githubusercontent.com/u/39227759?v=4", 88 | "gravatar_id": "", 89 | "url": "https://api.github.com/users/conky-ci", 90 | "html_url": "https://github.com/conky-ci", 91 | "followers_url": "https://api.github.com/users/conky-ci/followers", 92 | "following_url": "https://api.github.com/users/conky-ci/following{/other_user}", 93 | "gists_url": "https://api.github.com/users/conky-ci/gists{/gist_id}", 94 | "starred_url": "https://api.github.com/users/conky-ci/starred{/owner}{/repo}", 95 | "subscriptions_url": "https://api.github.com/users/conky-ci/subscriptions", 96 | "organizations_url": "https://api.github.com/users/conky-ci/orgs", 97 | "repos_url": "https://api.github.com/users/conky-ci/repos", 98 | "events_url": "https://api.github.com/users/conky-ci/events{/privacy}", 99 | "received_events_url": "https://api.github.com/users/conky-ci/received_events", 100 | "type": "User", 101 | "site_admin": false 102 | }, 103 | "html_url": "https://github.com/conky-ci/labhub-test", 104 | "description": null, 105 | "fork": true, 106 | "url": "https://api.github.com/repos/conky-ci/labhub-test", 107 | "forks_url": "https://api.github.com/repos/conky-ci/labhub-test/forks", 108 | "keys_url": "https://api.github.com/repos/conky-ci/labhub-test/keys{/key_id}", 109 | "collaborators_url": "https://api.github.com/repos/conky-ci/labhub-test/collaborators{/collaborator}", 110 | "teams_url": "https://api.github.com/repos/conky-ci/labhub-test/teams", 111 | "hooks_url": "https://api.github.com/repos/conky-ci/labhub-test/hooks", 112 | "issue_events_url": "https://api.github.com/repos/conky-ci/labhub-test/issues/events{/number}", 113 | "events_url": "https://api.github.com/repos/conky-ci/labhub-test/events", 114 | "assignees_url": "https://api.github.com/repos/conky-ci/labhub-test/assignees{/user}", 115 | "branches_url": "https://api.github.com/repos/conky-ci/labhub-test/branches{/branch}", 116 | "tags_url": "https://api.github.com/repos/conky-ci/labhub-test/tags", 117 | "blobs_url": "https://api.github.com/repos/conky-ci/labhub-test/git/blobs{/sha}", 118 | "git_tags_url": "https://api.github.com/repos/conky-ci/labhub-test/git/tags{/sha}", 119 | "git_refs_url": "https://api.github.com/repos/conky-ci/labhub-test/git/refs{/sha}", 120 | "trees_url": "https://api.github.com/repos/conky-ci/labhub-test/git/trees{/sha}", 121 | "statuses_url": "https://api.github.com/repos/conky-ci/labhub-test/statuses/{sha}", 122 | "languages_url": "https://api.github.com/repos/conky-ci/labhub-test/languages", 123 | "stargazers_url": "https://api.github.com/repos/conky-ci/labhub-test/stargazers", 124 | "contributors_url": "https://api.github.com/repos/conky-ci/labhub-test/contributors", 125 | "subscribers_url": "https://api.github.com/repos/conky-ci/labhub-test/subscribers", 126 | "subscription_url": "https://api.github.com/repos/conky-ci/labhub-test/subscription", 127 | "commits_url": "https://api.github.com/repos/conky-ci/labhub-test/commits{/sha}", 128 | "git_commits_url": "https://api.github.com/repos/conky-ci/labhub-test/git/commits{/sha}", 129 | "comments_url": "https://api.github.com/repos/conky-ci/labhub-test/comments{/number}", 130 | "issue_comment_url": "https://api.github.com/repos/conky-ci/labhub-test/issues/comments{/number}", 131 | "contents_url": "https://api.github.com/repos/conky-ci/labhub-test/contents/{+path}", 132 | "compare_url": "https://api.github.com/repos/conky-ci/labhub-test/compare/{base}...{head}", 133 | "merges_url": "https://api.github.com/repos/conky-ci/labhub-test/merges", 134 | "archive_url": "https://api.github.com/repos/conky-ci/labhub-test/{archive_format}{/ref}", 135 | "downloads_url": "https://api.github.com/repos/conky-ci/labhub-test/downloads", 136 | "issues_url": "https://api.github.com/repos/conky-ci/labhub-test/issues{/number}", 137 | "pulls_url": "https://api.github.com/repos/conky-ci/labhub-test/pulls{/number}", 138 | "milestones_url": "https://api.github.com/repos/conky-ci/labhub-test/milestones{/number}", 139 | "notifications_url": "https://api.github.com/repos/conky-ci/labhub-test/notifications{?since,all,participating}", 140 | "labels_url": "https://api.github.com/repos/conky-ci/labhub-test/labels{/name}", 141 | "releases_url": "https://api.github.com/repos/conky-ci/labhub-test/releases{/id}", 142 | "deployments_url": "https://api.github.com/repos/conky-ci/labhub-test/deployments", 143 | "created_at": "2019-03-02T23:54:15Z", 144 | "updated_at": "2019-03-02T23:54:57Z", 145 | "pushed_at": "2019-03-02T23:54:56Z", 146 | "git_url": "git://github.com/conky-ci/labhub-test.git", 147 | "ssh_url": "git@github.com:conky-ci/labhub-test.git", 148 | "clone_url": "https://github.com/conky-ci/labhub-test.git", 149 | "svn_url": "https://github.com/conky-ci/labhub-test", 150 | "homepage": null, 151 | "size": 1, 152 | "stargazers_count": 0, 153 | "watchers_count": 0, 154 | "language": null, 155 | "has_issues": false, 156 | "has_projects": true, 157 | "has_downloads": true, 158 | "has_wiki": true, 159 | "has_pages": false, 160 | "forks_count": 0, 161 | "mirror_url": null, 162 | "archived": false, 163 | "open_issues_count": 0, 164 | "license": null, 165 | "forks": 0, 166 | "open_issues": 0, 167 | "watchers": 0, 168 | "default_branch": "master" 169 | } 170 | }, 171 | "base": { 172 | "label": "brndnmtthws:master", 173 | "ref": "master", 174 | "sha": "93b58a9136e63589cc21d5df69b36cc84cdfc6db", 175 | "user": { 176 | "login": "brndnmtthws", 177 | "id": 3129093, 178 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 179 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 180 | "gravatar_id": "", 181 | "url": "https://api.github.com/users/brndnmtthws", 182 | "html_url": "https://github.com/brndnmtthws", 183 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 184 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 185 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 186 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 187 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 188 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 189 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 190 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 191 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 192 | "type": "User", 193 | "site_admin": false 194 | }, 195 | "repo": { 196 | "id": 173389683, 197 | "node_id": "MDEwOlJlcG9zaXRvcnkxNzMzODk2ODM=", 198 | "name": "labhub-test", 199 | "full_name": "brndnmtthws/labhub-test", 200 | "private": false, 201 | "owner": { 202 | "login": "brndnmtthws", 203 | "id": 3129093, 204 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 205 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 206 | "gravatar_id": "", 207 | "url": "https://api.github.com/users/brndnmtthws", 208 | "html_url": "https://github.com/brndnmtthws", 209 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 210 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 211 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 212 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 213 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 214 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 215 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 216 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 217 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 218 | "type": "User", 219 | "site_admin": false 220 | }, 221 | "html_url": "https://github.com/brndnmtthws/labhub-test", 222 | "description": null, 223 | "fork": false, 224 | "url": "https://api.github.com/repos/brndnmtthws/labhub-test", 225 | "forks_url": "https://api.github.com/repos/brndnmtthws/labhub-test/forks", 226 | "keys_url": "https://api.github.com/repos/brndnmtthws/labhub-test/keys{/key_id}", 227 | "collaborators_url": "https://api.github.com/repos/brndnmtthws/labhub-test/collaborators{/collaborator}", 228 | "teams_url": "https://api.github.com/repos/brndnmtthws/labhub-test/teams", 229 | "hooks_url": "https://api.github.com/repos/brndnmtthws/labhub-test/hooks", 230 | "issue_events_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/events{/number}", 231 | "events_url": "https://api.github.com/repos/brndnmtthws/labhub-test/events", 232 | "assignees_url": "https://api.github.com/repos/brndnmtthws/labhub-test/assignees{/user}", 233 | "branches_url": "https://api.github.com/repos/brndnmtthws/labhub-test/branches{/branch}", 234 | "tags_url": "https://api.github.com/repos/brndnmtthws/labhub-test/tags", 235 | "blobs_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/blobs{/sha}", 236 | "git_tags_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/tags{/sha}", 237 | "git_refs_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/refs{/sha}", 238 | "trees_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/trees{/sha}", 239 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub-test/statuses/{sha}", 240 | "languages_url": "https://api.github.com/repos/brndnmtthws/labhub-test/languages", 241 | "stargazers_url": "https://api.github.com/repos/brndnmtthws/labhub-test/stargazers", 242 | "contributors_url": "https://api.github.com/repos/brndnmtthws/labhub-test/contributors", 243 | "subscribers_url": "https://api.github.com/repos/brndnmtthws/labhub-test/subscribers", 244 | "subscription_url": "https://api.github.com/repos/brndnmtthws/labhub-test/subscription", 245 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/commits{/sha}", 246 | "git_commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/commits{/sha}", 247 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/comments{/number}", 248 | "issue_comment_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/comments{/number}", 249 | "contents_url": "https://api.github.com/repos/brndnmtthws/labhub-test/contents/{+path}", 250 | "compare_url": "https://api.github.com/repos/brndnmtthws/labhub-test/compare/{base}...{head}", 251 | "merges_url": "https://api.github.com/repos/brndnmtthws/labhub-test/merges", 252 | "archive_url": "https://api.github.com/repos/brndnmtthws/labhub-test/{archive_format}{/ref}", 253 | "downloads_url": "https://api.github.com/repos/brndnmtthws/labhub-test/downloads", 254 | "issues_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues{/number}", 255 | "pulls_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls{/number}", 256 | "milestones_url": "https://api.github.com/repos/brndnmtthws/labhub-test/milestones{/number}", 257 | "notifications_url": "https://api.github.com/repos/brndnmtthws/labhub-test/notifications{?since,all,participating}", 258 | "labels_url": "https://api.github.com/repos/brndnmtthws/labhub-test/labels{/name}", 259 | "releases_url": "https://api.github.com/repos/brndnmtthws/labhub-test/releases{/id}", 260 | "deployments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/deployments", 261 | "created_at": "2019-03-02T01:31:48Z", 262 | "updated_at": "2019-03-02T23:54:14Z", 263 | "pushed_at": "2019-03-02T23:56:09Z", 264 | "git_url": "git://github.com/brndnmtthws/labhub-test.git", 265 | "ssh_url": "git@github.com:brndnmtthws/labhub-test.git", 266 | "clone_url": "https://github.com/brndnmtthws/labhub-test.git", 267 | "svn_url": "https://github.com/brndnmtthws/labhub-test", 268 | "homepage": null, 269 | "size": 1, 270 | "stargazers_count": 0, 271 | "watchers_count": 0, 272 | "language": null, 273 | "has_issues": true, 274 | "has_projects": true, 275 | "has_downloads": true, 276 | "has_wiki": true, 277 | "has_pages": false, 278 | "forks_count": 1, 279 | "mirror_url": null, 280 | "archived": false, 281 | "open_issues_count": 1, 282 | "license": null, 283 | "forks": 1, 284 | "open_issues": 1, 285 | "watchers": 0, 286 | "default_branch": "master" 287 | } 288 | }, 289 | "_links": { 290 | "self": { 291 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/5" 292 | }, 293 | "html": { 294 | "href": "https://github.com/brndnmtthws/labhub-test/pull/5" 295 | }, 296 | "issue": { 297 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/5" 298 | }, 299 | "comments": { 300 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/5/comments" 301 | }, 302 | "review_comments": { 303 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/5/comments" 304 | }, 305 | "review_comment": { 306 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/comments{/number}" 307 | }, 308 | "commits": { 309 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/5/commits" 310 | }, 311 | "statuses": { 312 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/statuses/2902de5e1c2993c0abd6a12bb126c69512ad3741" 313 | } 314 | }, 315 | "author_association": "COLLABORATOR", 316 | "draft": false, 317 | "merged": false, 318 | "mergeable": null, 319 | "rebaseable": null, 320 | "mergeable_state": "unknown", 321 | "merged_by": null, 322 | "comments": 0, 323 | "review_comments": 0, 324 | "maintainer_can_modify": true, 325 | "commits": 1, 326 | "additions": 1, 327 | "deletions": 0, 328 | "changed_files": 1 329 | }, 330 | "repository": { 331 | "id": 173389683, 332 | "node_id": "MDEwOlJlcG9zaXRvcnkxNzMzODk2ODM=", 333 | "name": "labhub-test", 334 | "full_name": "brndnmtthws/labhub-test", 335 | "private": false, 336 | "owner": { 337 | "login": "brndnmtthws", 338 | "id": 3129093, 339 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 340 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 341 | "gravatar_id": "", 342 | "url": "https://api.github.com/users/brndnmtthws", 343 | "html_url": "https://github.com/brndnmtthws", 344 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 345 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 346 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 347 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 348 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 349 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 350 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 351 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 352 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 353 | "type": "User", 354 | "site_admin": false 355 | }, 356 | "html_url": "https://github.com/brndnmtthws/labhub-test", 357 | "description": null, 358 | "fork": false, 359 | "url": "https://api.github.com/repos/brndnmtthws/labhub-test", 360 | "forks_url": "https://api.github.com/repos/brndnmtthws/labhub-test/forks", 361 | "keys_url": "https://api.github.com/repos/brndnmtthws/labhub-test/keys{/key_id}", 362 | "collaborators_url": "https://api.github.com/repos/brndnmtthws/labhub-test/collaborators{/collaborator}", 363 | "teams_url": "https://api.github.com/repos/brndnmtthws/labhub-test/teams", 364 | "hooks_url": "https://api.github.com/repos/brndnmtthws/labhub-test/hooks", 365 | "issue_events_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/events{/number}", 366 | "events_url": "https://api.github.com/repos/brndnmtthws/labhub-test/events", 367 | "assignees_url": "https://api.github.com/repos/brndnmtthws/labhub-test/assignees{/user}", 368 | "branches_url": "https://api.github.com/repos/brndnmtthws/labhub-test/branches{/branch}", 369 | "tags_url": "https://api.github.com/repos/brndnmtthws/labhub-test/tags", 370 | "blobs_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/blobs{/sha}", 371 | "git_tags_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/tags{/sha}", 372 | "git_refs_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/refs{/sha}", 373 | "trees_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/trees{/sha}", 374 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub-test/statuses/{sha}", 375 | "languages_url": "https://api.github.com/repos/brndnmtthws/labhub-test/languages", 376 | "stargazers_url": "https://api.github.com/repos/brndnmtthws/labhub-test/stargazers", 377 | "contributors_url": "https://api.github.com/repos/brndnmtthws/labhub-test/contributors", 378 | "subscribers_url": "https://api.github.com/repos/brndnmtthws/labhub-test/subscribers", 379 | "subscription_url": "https://api.github.com/repos/brndnmtthws/labhub-test/subscription", 380 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/commits{/sha}", 381 | "git_commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/commits{/sha}", 382 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/comments{/number}", 383 | "issue_comment_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/comments{/number}", 384 | "contents_url": "https://api.github.com/repos/brndnmtthws/labhub-test/contents/{+path}", 385 | "compare_url": "https://api.github.com/repos/brndnmtthws/labhub-test/compare/{base}...{head}", 386 | "merges_url": "https://api.github.com/repos/brndnmtthws/labhub-test/merges", 387 | "archive_url": "https://api.github.com/repos/brndnmtthws/labhub-test/{archive_format}{/ref}", 388 | "downloads_url": "https://api.github.com/repos/brndnmtthws/labhub-test/downloads", 389 | "issues_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues{/number}", 390 | "pulls_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls{/number}", 391 | "milestones_url": "https://api.github.com/repos/brndnmtthws/labhub-test/milestones{/number}", 392 | "notifications_url": "https://api.github.com/repos/brndnmtthws/labhub-test/notifications{?since,all,participating}", 393 | "labels_url": "https://api.github.com/repos/brndnmtthws/labhub-test/labels{/name}", 394 | "releases_url": "https://api.github.com/repos/brndnmtthws/labhub-test/releases{/id}", 395 | "deployments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/deployments", 396 | "created_at": "2019-03-02T01:31:48Z", 397 | "updated_at": "2019-03-02T23:54:14Z", 398 | "pushed_at": "2019-03-02T23:56:09Z", 399 | "git_url": "git://github.com/brndnmtthws/labhub-test.git", 400 | "ssh_url": "git@github.com:brndnmtthws/labhub-test.git", 401 | "clone_url": "https://github.com/brndnmtthws/labhub-test.git", 402 | "svn_url": "https://github.com/brndnmtthws/labhub-test", 403 | "homepage": null, 404 | "size": 1, 405 | "stargazers_count": 0, 406 | "watchers_count": 0, 407 | "language": null, 408 | "has_issues": true, 409 | "has_projects": true, 410 | "has_downloads": true, 411 | "has_wiki": true, 412 | "has_pages": false, 413 | "forks_count": 1, 414 | "mirror_url": null, 415 | "archived": false, 416 | "open_issues_count": 1, 417 | "license": null, 418 | "forks": 1, 419 | "open_issues": 1, 420 | "watchers": 0, 421 | "default_branch": "master" 422 | }, 423 | "sender": { 424 | "login": "conky-ci", 425 | "id": 39227759, 426 | "node_id": "MDQ6VXNlcjM5MjI3NzU5", 427 | "avatar_url": "https://avatars3.githubusercontent.com/u/39227759?v=4", 428 | "gravatar_id": "", 429 | "url": "https://api.github.com/users/conky-ci", 430 | "html_url": "https://github.com/conky-ci", 431 | "followers_url": "https://api.github.com/users/conky-ci/followers", 432 | "following_url": "https://api.github.com/users/conky-ci/following{/other_user}", 433 | "gists_url": "https://api.github.com/users/conky-ci/gists{/gist_id}", 434 | "starred_url": "https://api.github.com/users/conky-ci/starred{/owner}{/repo}", 435 | "subscriptions_url": "https://api.github.com/users/conky-ci/subscriptions", 436 | "organizations_url": "https://api.github.com/users/conky-ci/orgs", 437 | "repos_url": "https://api.github.com/users/conky-ci/repos", 438 | "events_url": "https://api.github.com/users/conky-ci/events{/privacy}", 439 | "received_events_url": "https://api.github.com/users/conky-ci/received_events", 440 | "type": "User", 441 | "site_admin": false 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/testdata/github_open_pull_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "number": 1, 4 | "pull_request": { 5 | "url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/1", 6 | "id": 257629237, 7 | "node_id": "MDExOlB1bGxSZXF1ZXN0MjU3NjI5MjM3", 8 | "html_url": "https://github.com/brndnmtthws/labhub-test/pull/1", 9 | "diff_url": "https://github.com/brndnmtthws/labhub-test/pull/1.diff", 10 | "patch_url": "https://github.com/brndnmtthws/labhub-test/pull/1.patch", 11 | "issue_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/1", 12 | "number": 1, 13 | "state": "open", 14 | "locked": false, 15 | "title": "Update README.md", 16 | "user": { 17 | "login": "brndnmtthws", 18 | "id": 3129093, 19 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 20 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 21 | "gravatar_id": "", 22 | "url": "https://api.github.com/users/brndnmtthws", 23 | "html_url": "https://github.com/brndnmtthws", 24 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 25 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 26 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 27 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 28 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 29 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 30 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 31 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 32 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 33 | "type": "User", 34 | "site_admin": false 35 | }, 36 | "body": "", 37 | "created_at": "2019-03-02T01:35:20Z", 38 | "updated_at": "2019-03-02T01:35:20Z", 39 | "closed_at": null, 40 | "merged_at": null, 41 | "merge_commit_sha": null, 42 | "assignee": null, 43 | "assignees": [], 44 | "requested_reviewers": [], 45 | "requested_teams": [], 46 | "labels": [], 47 | "milestone": null, 48 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/1/commits", 49 | "review_comments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/1/comments", 50 | "review_comment_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/comments{/number}", 51 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/1/comments", 52 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub-test/statuses/648d988e8381ddb3d03b801657bf4f4d0a706e75", 53 | "head": { 54 | "label": "brndnmtthws:brndnmtthws-patch-1", 55 | "ref": "brndnmtthws-patch-1", 56 | "sha": "648d988e8381ddb3d03b801657bf4f4d0a706e75", 57 | "user": { 58 | "login": "brndnmtthws", 59 | "id": 3129093, 60 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 61 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 62 | "gravatar_id": "", 63 | "url": "https://api.github.com/users/brndnmtthws", 64 | "html_url": "https://github.com/brndnmtthws", 65 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 66 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 67 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 68 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 69 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 70 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 71 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 72 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 73 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 74 | "type": "User", 75 | "site_admin": false 76 | }, 77 | "repo": { 78 | "id": 173389683, 79 | "node_id": "MDEwOlJlcG9zaXRvcnkxNzMzODk2ODM=", 80 | "name": "labhub-test", 81 | "full_name": "brndnmtthws/labhub-test", 82 | "private": false, 83 | "owner": { 84 | "login": "brndnmtthws", 85 | "id": 3129093, 86 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 87 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 88 | "gravatar_id": "", 89 | "url": "https://api.github.com/users/brndnmtthws", 90 | "html_url": "https://github.com/brndnmtthws", 91 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 92 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 93 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 94 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 95 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 96 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 97 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 98 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 99 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 100 | "type": "User", 101 | "site_admin": false 102 | }, 103 | "html_url": "https://github.com/brndnmtthws/labhub-test", 104 | "description": null, 105 | "fork": false, 106 | "url": "https://api.github.com/repos/brndnmtthws/labhub-test", 107 | "forks_url": "https://api.github.com/repos/brndnmtthws/labhub-test/forks", 108 | "keys_url": "https://api.github.com/repos/brndnmtthws/labhub-test/keys{/key_id}", 109 | "collaborators_url": "https://api.github.com/repos/brndnmtthws/labhub-test/collaborators{/collaborator}", 110 | "teams_url": "https://api.github.com/repos/brndnmtthws/labhub-test/teams", 111 | "hooks_url": "https://api.github.com/repos/brndnmtthws/labhub-test/hooks", 112 | "issue_events_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/events{/number}", 113 | "events_url": "https://api.github.com/repos/brndnmtthws/labhub-test/events", 114 | "assignees_url": "https://api.github.com/repos/brndnmtthws/labhub-test/assignees{/user}", 115 | "branches_url": "https://api.github.com/repos/brndnmtthws/labhub-test/branches{/branch}", 116 | "tags_url": "https://api.github.com/repos/brndnmtthws/labhub-test/tags", 117 | "blobs_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/blobs{/sha}", 118 | "git_tags_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/tags{/sha}", 119 | "git_refs_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/refs{/sha}", 120 | "trees_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/trees{/sha}", 121 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub-test/statuses/{sha}", 122 | "languages_url": "https://api.github.com/repos/brndnmtthws/labhub-test/languages", 123 | "stargazers_url": "https://api.github.com/repos/brndnmtthws/labhub-test/stargazers", 124 | "contributors_url": "https://api.github.com/repos/brndnmtthws/labhub-test/contributors", 125 | "subscribers_url": "https://api.github.com/repos/brndnmtthws/labhub-test/subscribers", 126 | "subscription_url": "https://api.github.com/repos/brndnmtthws/labhub-test/subscription", 127 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/commits{/sha}", 128 | "git_commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/commits{/sha}", 129 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/comments{/number}", 130 | "issue_comment_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/comments{/number}", 131 | "contents_url": "https://api.github.com/repos/brndnmtthws/labhub-test/contents/{+path}", 132 | "compare_url": "https://api.github.com/repos/brndnmtthws/labhub-test/compare/{base}...{head}", 133 | "merges_url": "https://api.github.com/repos/brndnmtthws/labhub-test/merges", 134 | "archive_url": "https://api.github.com/repos/brndnmtthws/labhub-test/{archive_format}{/ref}", 135 | "downloads_url": "https://api.github.com/repos/brndnmtthws/labhub-test/downloads", 136 | "issues_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues{/number}", 137 | "pulls_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls{/number}", 138 | "milestones_url": "https://api.github.com/repos/brndnmtthws/labhub-test/milestones{/number}", 139 | "notifications_url": "https://api.github.com/repos/brndnmtthws/labhub-test/notifications{?since,all,participating}", 140 | "labels_url": "https://api.github.com/repos/brndnmtthws/labhub-test/labels{/name}", 141 | "releases_url": "https://api.github.com/repos/brndnmtthws/labhub-test/releases{/id}", 142 | "deployments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/deployments", 143 | "created_at": "2019-03-02T01:31:48Z", 144 | "updated_at": "2019-03-02T01:31:51Z", 145 | "pushed_at": "2019-03-02T01:35:14Z", 146 | "git_url": "git://github.com/brndnmtthws/labhub-test.git", 147 | "ssh_url": "git@github.com:brndnmtthws/labhub-test.git", 148 | "clone_url": "https://github.com/brndnmtthws/labhub-test.git", 149 | "svn_url": "https://github.com/brndnmtthws/labhub-test", 150 | "homepage": null, 151 | "size": 0, 152 | "stargazers_count": 0, 153 | "watchers_count": 0, 154 | "language": null, 155 | "has_issues": true, 156 | "has_projects": true, 157 | "has_downloads": true, 158 | "has_wiki": true, 159 | "has_pages": false, 160 | "forks_count": 0, 161 | "mirror_url": null, 162 | "archived": false, 163 | "open_issues_count": 1, 164 | "license": null, 165 | "forks": 0, 166 | "open_issues": 1, 167 | "watchers": 0, 168 | "default_branch": "master" 169 | } 170 | }, 171 | "base": { 172 | "label": "brndnmtthws:master", 173 | "ref": "master", 174 | "sha": "6edb1d5aac7ca29e786ad2929077156a0934d41c", 175 | "user": { 176 | "login": "brndnmtthws", 177 | "id": 3129093, 178 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 179 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 180 | "gravatar_id": "", 181 | "url": "https://api.github.com/users/brndnmtthws", 182 | "html_url": "https://github.com/brndnmtthws", 183 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 184 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 185 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 186 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 187 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 188 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 189 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 190 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 191 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 192 | "type": "User", 193 | "site_admin": false 194 | }, 195 | "repo": { 196 | "id": 173389683, 197 | "node_id": "MDEwOlJlcG9zaXRvcnkxNzMzODk2ODM=", 198 | "name": "labhub-test", 199 | "full_name": "brndnmtthws/labhub-test", 200 | "private": false, 201 | "owner": { 202 | "login": "brndnmtthws", 203 | "id": 3129093, 204 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 205 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 206 | "gravatar_id": "", 207 | "url": "https://api.github.com/users/brndnmtthws", 208 | "html_url": "https://github.com/brndnmtthws", 209 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 210 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 211 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 212 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 213 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 214 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 215 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 216 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 217 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 218 | "type": "User", 219 | "site_admin": false 220 | }, 221 | "html_url": "https://github.com/brndnmtthws/labhub-test", 222 | "description": null, 223 | "fork": false, 224 | "url": "https://api.github.com/repos/brndnmtthws/labhub-test", 225 | "forks_url": "https://api.github.com/repos/brndnmtthws/labhub-test/forks", 226 | "keys_url": "https://api.github.com/repos/brndnmtthws/labhub-test/keys{/key_id}", 227 | "collaborators_url": "https://api.github.com/repos/brndnmtthws/labhub-test/collaborators{/collaborator}", 228 | "teams_url": "https://api.github.com/repos/brndnmtthws/labhub-test/teams", 229 | "hooks_url": "https://api.github.com/repos/brndnmtthws/labhub-test/hooks", 230 | "issue_events_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/events{/number}", 231 | "events_url": "https://api.github.com/repos/brndnmtthws/labhub-test/events", 232 | "assignees_url": "https://api.github.com/repos/brndnmtthws/labhub-test/assignees{/user}", 233 | "branches_url": "https://api.github.com/repos/brndnmtthws/labhub-test/branches{/branch}", 234 | "tags_url": "https://api.github.com/repos/brndnmtthws/labhub-test/tags", 235 | "blobs_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/blobs{/sha}", 236 | "git_tags_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/tags{/sha}", 237 | "git_refs_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/refs{/sha}", 238 | "trees_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/trees{/sha}", 239 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub-test/statuses/{sha}", 240 | "languages_url": "https://api.github.com/repos/brndnmtthws/labhub-test/languages", 241 | "stargazers_url": "https://api.github.com/repos/brndnmtthws/labhub-test/stargazers", 242 | "contributors_url": "https://api.github.com/repos/brndnmtthws/labhub-test/contributors", 243 | "subscribers_url": "https://api.github.com/repos/brndnmtthws/labhub-test/subscribers", 244 | "subscription_url": "https://api.github.com/repos/brndnmtthws/labhub-test/subscription", 245 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/commits{/sha}", 246 | "git_commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/commits{/sha}", 247 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/comments{/number}", 248 | "issue_comment_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/comments{/number}", 249 | "contents_url": "https://api.github.com/repos/brndnmtthws/labhub-test/contents/{+path}", 250 | "compare_url": "https://api.github.com/repos/brndnmtthws/labhub-test/compare/{base}...{head}", 251 | "merges_url": "https://api.github.com/repos/brndnmtthws/labhub-test/merges", 252 | "archive_url": "https://api.github.com/repos/brndnmtthws/labhub-test/{archive_format}{/ref}", 253 | "downloads_url": "https://api.github.com/repos/brndnmtthws/labhub-test/downloads", 254 | "issues_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues{/number}", 255 | "pulls_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls{/number}", 256 | "milestones_url": "https://api.github.com/repos/brndnmtthws/labhub-test/milestones{/number}", 257 | "notifications_url": "https://api.github.com/repos/brndnmtthws/labhub-test/notifications{?since,all,participating}", 258 | "labels_url": "https://api.github.com/repos/brndnmtthws/labhub-test/labels{/name}", 259 | "releases_url": "https://api.github.com/repos/brndnmtthws/labhub-test/releases{/id}", 260 | "deployments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/deployments", 261 | "created_at": "2019-03-02T01:31:48Z", 262 | "updated_at": "2019-03-02T01:31:51Z", 263 | "pushed_at": "2019-03-02T01:35:14Z", 264 | "git_url": "git://github.com/brndnmtthws/labhub-test.git", 265 | "ssh_url": "git@github.com:brndnmtthws/labhub-test.git", 266 | "clone_url": "https://github.com/brndnmtthws/labhub-test.git", 267 | "svn_url": "https://github.com/brndnmtthws/labhub-test", 268 | "homepage": null, 269 | "size": 0, 270 | "stargazers_count": 0, 271 | "watchers_count": 0, 272 | "language": null, 273 | "has_issues": true, 274 | "has_projects": true, 275 | "has_downloads": true, 276 | "has_wiki": true, 277 | "has_pages": false, 278 | "forks_count": 0, 279 | "mirror_url": null, 280 | "archived": false, 281 | "open_issues_count": 1, 282 | "license": null, 283 | "forks": 0, 284 | "open_issues": 1, 285 | "watchers": 0, 286 | "default_branch": "master" 287 | } 288 | }, 289 | "_links": { 290 | "self": { 291 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/1" 292 | }, 293 | "html": { 294 | "href": "https://github.com/brndnmtthws/labhub-test/pull/1" 295 | }, 296 | "issue": { 297 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/1" 298 | }, 299 | "comments": { 300 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/1/comments" 301 | }, 302 | "review_comments": { 303 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/1/comments" 304 | }, 305 | "review_comment": { 306 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/comments{/number}" 307 | }, 308 | "commits": { 309 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls/1/commits" 310 | }, 311 | "statuses": { 312 | "href": "https://api.github.com/repos/brndnmtthws/labhub-test/statuses/648d988e8381ddb3d03b801657bf4f4d0a706e75" 313 | } 314 | }, 315 | "author_association": "OWNER", 316 | "draft": false, 317 | "merged": false, 318 | "mergeable": null, 319 | "rebaseable": null, 320 | "mergeable_state": "unknown", 321 | "merged_by": null, 322 | "comments": 0, 323 | "review_comments": 0, 324 | "maintainer_can_modify": false, 325 | "commits": 1, 326 | "additions": 3, 327 | "deletions": 1, 328 | "changed_files": 1 329 | }, 330 | "repository": { 331 | "id": 173389683, 332 | "node_id": "MDEwOlJlcG9zaXRvcnkxNzMzODk2ODM=", 333 | "name": "labhub-test", 334 | "full_name": "brndnmtthws/labhub-test", 335 | "private": false, 336 | "owner": { 337 | "login": "brndnmtthws", 338 | "id": 3129093, 339 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 340 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 341 | "gravatar_id": "", 342 | "url": "https://api.github.com/users/brndnmtthws", 343 | "html_url": "https://github.com/brndnmtthws", 344 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 345 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 346 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 347 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 348 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 349 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 350 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 351 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 352 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 353 | "type": "User", 354 | "site_admin": false 355 | }, 356 | "html_url": "https://github.com/brndnmtthws/labhub-test", 357 | "description": null, 358 | "fork": false, 359 | "url": "https://api.github.com/repos/brndnmtthws/labhub-test", 360 | "forks_url": "https://api.github.com/repos/brndnmtthws/labhub-test/forks", 361 | "keys_url": "https://api.github.com/repos/brndnmtthws/labhub-test/keys{/key_id}", 362 | "collaborators_url": "https://api.github.com/repos/brndnmtthws/labhub-test/collaborators{/collaborator}", 363 | "teams_url": "https://api.github.com/repos/brndnmtthws/labhub-test/teams", 364 | "hooks_url": "https://api.github.com/repos/brndnmtthws/labhub-test/hooks", 365 | "issue_events_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/events{/number}", 366 | "events_url": "https://api.github.com/repos/brndnmtthws/labhub-test/events", 367 | "assignees_url": "https://api.github.com/repos/brndnmtthws/labhub-test/assignees{/user}", 368 | "branches_url": "https://api.github.com/repos/brndnmtthws/labhub-test/branches{/branch}", 369 | "tags_url": "https://api.github.com/repos/brndnmtthws/labhub-test/tags", 370 | "blobs_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/blobs{/sha}", 371 | "git_tags_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/tags{/sha}", 372 | "git_refs_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/refs{/sha}", 373 | "trees_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/trees{/sha}", 374 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub-test/statuses/{sha}", 375 | "languages_url": "https://api.github.com/repos/brndnmtthws/labhub-test/languages", 376 | "stargazers_url": "https://api.github.com/repos/brndnmtthws/labhub-test/stargazers", 377 | "contributors_url": "https://api.github.com/repos/brndnmtthws/labhub-test/contributors", 378 | "subscribers_url": "https://api.github.com/repos/brndnmtthws/labhub-test/subscribers", 379 | "subscription_url": "https://api.github.com/repos/brndnmtthws/labhub-test/subscription", 380 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/commits{/sha}", 381 | "git_commits_url": "https://api.github.com/repos/brndnmtthws/labhub-test/git/commits{/sha}", 382 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/comments{/number}", 383 | "issue_comment_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues/comments{/number}", 384 | "contents_url": "https://api.github.com/repos/brndnmtthws/labhub-test/contents/{+path}", 385 | "compare_url": "https://api.github.com/repos/brndnmtthws/labhub-test/compare/{base}...{head}", 386 | "merges_url": "https://api.github.com/repos/brndnmtthws/labhub-test/merges", 387 | "archive_url": "https://api.github.com/repos/brndnmtthws/labhub-test/{archive_format}{/ref}", 388 | "downloads_url": "https://api.github.com/repos/brndnmtthws/labhub-test/downloads", 389 | "issues_url": "https://api.github.com/repos/brndnmtthws/labhub-test/issues{/number}", 390 | "pulls_url": "https://api.github.com/repos/brndnmtthws/labhub-test/pulls{/number}", 391 | "milestones_url": "https://api.github.com/repos/brndnmtthws/labhub-test/milestones{/number}", 392 | "notifications_url": "https://api.github.com/repos/brndnmtthws/labhub-test/notifications{?since,all,participating}", 393 | "labels_url": "https://api.github.com/repos/brndnmtthws/labhub-test/labels{/name}", 394 | "releases_url": "https://api.github.com/repos/brndnmtthws/labhub-test/releases{/id}", 395 | "deployments_url": "https://api.github.com/repos/brndnmtthws/labhub-test/deployments", 396 | "created_at": "2019-03-02T01:31:48Z", 397 | "updated_at": "2019-03-02T01:31:51Z", 398 | "pushed_at": "2019-03-02T01:35:14Z", 399 | "git_url": "git://github.com/brndnmtthws/labhub-test.git", 400 | "ssh_url": "git@github.com:brndnmtthws/labhub-test.git", 401 | "clone_url": "https://github.com/brndnmtthws/labhub-test.git", 402 | "svn_url": "https://github.com/brndnmtthws/labhub-test", 403 | "homepage": null, 404 | "size": 0, 405 | "stargazers_count": 0, 406 | "watchers_count": 0, 407 | "language": null, 408 | "has_issues": true, 409 | "has_projects": true, 410 | "has_downloads": true, 411 | "has_wiki": true, 412 | "has_pages": false, 413 | "forks_count": 0, 414 | "mirror_url": null, 415 | "archived": false, 416 | "open_issues_count": 1, 417 | "license": null, 418 | "forks": 0, 419 | "open_issues": 1, 420 | "watchers": 0, 421 | "default_branch": "master" 422 | }, 423 | "sender": { 424 | "login": "brndnmtthws", 425 | "id": 3129093, 426 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 427 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 428 | "gravatar_id": "", 429 | "url": "https://api.github.com/users/brndnmtthws", 430 | "html_url": "https://github.com/brndnmtthws", 431 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 432 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 433 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 434 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 435 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 436 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 437 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 438 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 439 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 440 | "type": "User", 441 | "site_admin": false 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/testdata/github_reopen_pull_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "reopened", 3 | "number": 1, 4 | "pull_request": { 5 | "url": "https://api.github.com/repos/brndnmtthws/labhub/pulls/1", 6 | "id": 256776645, 7 | "node_id": "MDExOlB1bGxSZXF1ZXN0MjU2Nzc2NjQ1", 8 | "html_url": "https://github.com/brndnmtthws/labhub/pull/1", 9 | "diff_url": "https://github.com/brndnmtthws/labhub/pull/1.diff", 10 | "patch_url": "https://github.com/brndnmtthws/labhub/pull/1.patch", 11 | "issue_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/1", 12 | "number": 1, 13 | "state": "open", 14 | "locked": false, 15 | "title": "Update README.md", 16 | "user": { 17 | "login": "brndnmtthws", 18 | "id": 3129093, 19 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 20 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 21 | "gravatar_id": "", 22 | "url": "https://api.github.com/users/brndnmtthws", 23 | "html_url": "https://github.com/brndnmtthws", 24 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 25 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 26 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 27 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 28 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 29 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 30 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 31 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 32 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 33 | "type": "User", 34 | "site_admin": false 35 | }, 36 | "body": "", 37 | "created_at": "2019-02-27T17:41:52Z", 38 | "updated_at": "2019-03-01T18:18:35Z", 39 | "closed_at": null, 40 | "merged_at": null, 41 | "merge_commit_sha": "74d9144aa97bbd3ee9b6fb6fc637859d3a7b80ac", 42 | "assignee": null, 43 | "assignees": [], 44 | "requested_reviewers": [], 45 | "requested_teams": [], 46 | "labels": [], 47 | "milestone": null, 48 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub/pulls/1/commits", 49 | "review_comments_url": "https://api.github.com/repos/brndnmtthws/labhub/pulls/1/comments", 50 | "review_comment_url": "https://api.github.com/repos/brndnmtthws/labhub/pulls/comments{/number}", 51 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/1/comments", 52 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub/statuses/138f1ebf90d199f50f257ea5324f42a13fbc46df", 53 | "head": { 54 | "label": "brndnmtthws:brndnmtthws-patch-1", 55 | "ref": "brndnmtthws-patch-1", 56 | "sha": "138f1ebf90d199f50f257ea5324f42a13fbc46df", 57 | "user": { 58 | "login": "brndnmtthws", 59 | "id": 3129093, 60 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 61 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 62 | "gravatar_id": "", 63 | "url": "https://api.github.com/users/brndnmtthws", 64 | "html_url": "https://github.com/brndnmtthws", 65 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 66 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 67 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 68 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 69 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 70 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 71 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 72 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 73 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 74 | "type": "User", 75 | "site_admin": false 76 | }, 77 | "repo": { 78 | "id": 172714879, 79 | "node_id": "MDEwOlJlcG9zaXRvcnkxNzI3MTQ4Nzk=", 80 | "name": "labhub", 81 | "full_name": "brndnmtthws/labhub", 82 | "private": true, 83 | "owner": { 84 | "login": "brndnmtthws", 85 | "id": 3129093, 86 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 87 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 88 | "gravatar_id": "", 89 | "url": "https://api.github.com/users/brndnmtthws", 90 | "html_url": "https://github.com/brndnmtthws", 91 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 92 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 93 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 94 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 95 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 96 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 97 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 98 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 99 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 100 | "type": "User", 101 | "site_admin": false 102 | }, 103 | "html_url": "https://github.com/brndnmtthws/labhub", 104 | "description": null, 105 | "fork": false, 106 | "url": "https://api.github.com/repos/brndnmtthws/labhub", 107 | "forks_url": "https://api.github.com/repos/brndnmtthws/labhub/forks", 108 | "keys_url": "https://api.github.com/repos/brndnmtthws/labhub/keys{/key_id}", 109 | "collaborators_url": "https://api.github.com/repos/brndnmtthws/labhub/collaborators{/collaborator}", 110 | "teams_url": "https://api.github.com/repos/brndnmtthws/labhub/teams", 111 | "hooks_url": "https://api.github.com/repos/brndnmtthws/labhub/hooks", 112 | "issue_events_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/events{/number}", 113 | "events_url": "https://api.github.com/repos/brndnmtthws/labhub/events", 114 | "assignees_url": "https://api.github.com/repos/brndnmtthws/labhub/assignees{/user}", 115 | "branches_url": "https://api.github.com/repos/brndnmtthws/labhub/branches{/branch}", 116 | "tags_url": "https://api.github.com/repos/brndnmtthws/labhub/tags", 117 | "blobs_url": "https://api.github.com/repos/brndnmtthws/labhub/git/blobs{/sha}", 118 | "git_tags_url": "https://api.github.com/repos/brndnmtthws/labhub/git/tags{/sha}", 119 | "git_refs_url": "https://api.github.com/repos/brndnmtthws/labhub/git/refs{/sha}", 120 | "trees_url": "https://api.github.com/repos/brndnmtthws/labhub/git/trees{/sha}", 121 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub/statuses/{sha}", 122 | "languages_url": "https://api.github.com/repos/brndnmtthws/labhub/languages", 123 | "stargazers_url": "https://api.github.com/repos/brndnmtthws/labhub/stargazers", 124 | "contributors_url": "https://api.github.com/repos/brndnmtthws/labhub/contributors", 125 | "subscribers_url": "https://api.github.com/repos/brndnmtthws/labhub/subscribers", 126 | "subscription_url": "https://api.github.com/repos/brndnmtthws/labhub/subscription", 127 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub/commits{/sha}", 128 | "git_commits_url": "https://api.github.com/repos/brndnmtthws/labhub/git/commits{/sha}", 129 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub/comments{/number}", 130 | "issue_comment_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/comments{/number}", 131 | "contents_url": "https://api.github.com/repos/brndnmtthws/labhub/contents/{+path}", 132 | "compare_url": "https://api.github.com/repos/brndnmtthws/labhub/compare/{base}...{head}", 133 | "merges_url": "https://api.github.com/repos/brndnmtthws/labhub/merges", 134 | "archive_url": "https://api.github.com/repos/brndnmtthws/labhub/{archive_format}{/ref}", 135 | "downloads_url": "https://api.github.com/repos/brndnmtthws/labhub/downloads", 136 | "issues_url": "https://api.github.com/repos/brndnmtthws/labhub/issues{/number}", 137 | "pulls_url": "https://api.github.com/repos/brndnmtthws/labhub/pulls{/number}", 138 | "milestones_url": "https://api.github.com/repos/brndnmtthws/labhub/milestones{/number}", 139 | "notifications_url": "https://api.github.com/repos/brndnmtthws/labhub/notifications{?since,all,participating}", 140 | "labels_url": "https://api.github.com/repos/brndnmtthws/labhub/labels{/name}", 141 | "releases_url": "https://api.github.com/repos/brndnmtthws/labhub/releases{/id}", 142 | "deployments_url": "https://api.github.com/repos/brndnmtthws/labhub/deployments", 143 | "created_at": "2019-02-26T13:16:44Z", 144 | "updated_at": "2019-03-01T17:35:50Z", 145 | "pushed_at": "2019-03-01T17:35:48Z", 146 | "git_url": "git://github.com/brndnmtthws/labhub.git", 147 | "ssh_url": "git@github.com:brndnmtthws/labhub.git", 148 | "clone_url": "https://github.com/brndnmtthws/labhub.git", 149 | "svn_url": "https://github.com/brndnmtthws/labhub", 150 | "homepage": null, 151 | "size": 130, 152 | "stargazers_count": 0, 153 | "watchers_count": 0, 154 | "language": "Rust", 155 | "has_issues": true, 156 | "has_projects": true, 157 | "has_downloads": true, 158 | "has_wiki": true, 159 | "has_pages": false, 160 | "forks_count": 0, 161 | "mirror_url": null, 162 | "archived": false, 163 | "open_issues_count": 1, 164 | "license": { 165 | "key": "unlicense", 166 | "name": "The Unlicense", 167 | "spdx_id": "Unlicense", 168 | "url": "https://api.github.com/licenses/unlicense", 169 | "node_id": "MDc6TGljZW5zZTE1" 170 | }, 171 | "forks": 0, 172 | "open_issues": 1, 173 | "watchers": 0, 174 | "default_branch": "master" 175 | } 176 | }, 177 | "base": { 178 | "label": "brndnmtthws:master", 179 | "ref": "master", 180 | "sha": "d4e0bd904436d52cfe8907eabe4abde29af267a5", 181 | "user": { 182 | "login": "brndnmtthws", 183 | "id": 3129093, 184 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 185 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 186 | "gravatar_id": "", 187 | "url": "https://api.github.com/users/brndnmtthws", 188 | "html_url": "https://github.com/brndnmtthws", 189 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 190 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 191 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 192 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 193 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 194 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 195 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 196 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 197 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 198 | "type": "User", 199 | "site_admin": false 200 | }, 201 | "repo": { 202 | "id": 172714879, 203 | "node_id": "MDEwOlJlcG9zaXRvcnkxNzI3MTQ4Nzk=", 204 | "name": "labhub", 205 | "full_name": "brndnmtthws/labhub", 206 | "private": true, 207 | "owner": { 208 | "login": "brndnmtthws", 209 | "id": 3129093, 210 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 211 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 212 | "gravatar_id": "", 213 | "url": "https://api.github.com/users/brndnmtthws", 214 | "html_url": "https://github.com/brndnmtthws", 215 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 216 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 217 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 218 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 219 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 220 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 221 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 222 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 223 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 224 | "type": "User", 225 | "site_admin": false 226 | }, 227 | "html_url": "https://github.com/brndnmtthws/labhub", 228 | "description": null, 229 | "fork": false, 230 | "url": "https://api.github.com/repos/brndnmtthws/labhub", 231 | "forks_url": "https://api.github.com/repos/brndnmtthws/labhub/forks", 232 | "keys_url": "https://api.github.com/repos/brndnmtthws/labhub/keys{/key_id}", 233 | "collaborators_url": "https://api.github.com/repos/brndnmtthws/labhub/collaborators{/collaborator}", 234 | "teams_url": "https://api.github.com/repos/brndnmtthws/labhub/teams", 235 | "hooks_url": "https://api.github.com/repos/brndnmtthws/labhub/hooks", 236 | "issue_events_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/events{/number}", 237 | "events_url": "https://api.github.com/repos/brndnmtthws/labhub/events", 238 | "assignees_url": "https://api.github.com/repos/brndnmtthws/labhub/assignees{/user}", 239 | "branches_url": "https://api.github.com/repos/brndnmtthws/labhub/branches{/branch}", 240 | "tags_url": "https://api.github.com/repos/brndnmtthws/labhub/tags", 241 | "blobs_url": "https://api.github.com/repos/brndnmtthws/labhub/git/blobs{/sha}", 242 | "git_tags_url": "https://api.github.com/repos/brndnmtthws/labhub/git/tags{/sha}", 243 | "git_refs_url": "https://api.github.com/repos/brndnmtthws/labhub/git/refs{/sha}", 244 | "trees_url": "https://api.github.com/repos/brndnmtthws/labhub/git/trees{/sha}", 245 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub/statuses/{sha}", 246 | "languages_url": "https://api.github.com/repos/brndnmtthws/labhub/languages", 247 | "stargazers_url": "https://api.github.com/repos/brndnmtthws/labhub/stargazers", 248 | "contributors_url": "https://api.github.com/repos/brndnmtthws/labhub/contributors", 249 | "subscribers_url": "https://api.github.com/repos/brndnmtthws/labhub/subscribers", 250 | "subscription_url": "https://api.github.com/repos/brndnmtthws/labhub/subscription", 251 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub/commits{/sha}", 252 | "git_commits_url": "https://api.github.com/repos/brndnmtthws/labhub/git/commits{/sha}", 253 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub/comments{/number}", 254 | "issue_comment_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/comments{/number}", 255 | "contents_url": "https://api.github.com/repos/brndnmtthws/labhub/contents/{+path}", 256 | "compare_url": "https://api.github.com/repos/brndnmtthws/labhub/compare/{base}...{head}", 257 | "merges_url": "https://api.github.com/repos/brndnmtthws/labhub/merges", 258 | "archive_url": "https://api.github.com/repos/brndnmtthws/labhub/{archive_format}{/ref}", 259 | "downloads_url": "https://api.github.com/repos/brndnmtthws/labhub/downloads", 260 | "issues_url": "https://api.github.com/repos/brndnmtthws/labhub/issues{/number}", 261 | "pulls_url": "https://api.github.com/repos/brndnmtthws/labhub/pulls{/number}", 262 | "milestones_url": "https://api.github.com/repos/brndnmtthws/labhub/milestones{/number}", 263 | "notifications_url": "https://api.github.com/repos/brndnmtthws/labhub/notifications{?since,all,participating}", 264 | "labels_url": "https://api.github.com/repos/brndnmtthws/labhub/labels{/name}", 265 | "releases_url": "https://api.github.com/repos/brndnmtthws/labhub/releases{/id}", 266 | "deployments_url": "https://api.github.com/repos/brndnmtthws/labhub/deployments", 267 | "created_at": "2019-02-26T13:16:44Z", 268 | "updated_at": "2019-03-01T17:35:50Z", 269 | "pushed_at": "2019-03-01T17:35:48Z", 270 | "git_url": "git://github.com/brndnmtthws/labhub.git", 271 | "ssh_url": "git@github.com:brndnmtthws/labhub.git", 272 | "clone_url": "https://github.com/brndnmtthws/labhub.git", 273 | "svn_url": "https://github.com/brndnmtthws/labhub", 274 | "homepage": null, 275 | "size": 130, 276 | "stargazers_count": 0, 277 | "watchers_count": 0, 278 | "language": "Rust", 279 | "has_issues": true, 280 | "has_projects": true, 281 | "has_downloads": true, 282 | "has_wiki": true, 283 | "has_pages": false, 284 | "forks_count": 0, 285 | "mirror_url": null, 286 | "archived": false, 287 | "open_issues_count": 1, 288 | "license": { 289 | "key": "unlicense", 290 | "name": "The Unlicense", 291 | "spdx_id": "Unlicense", 292 | "url": "https://api.github.com/licenses/unlicense", 293 | "node_id": "MDc6TGljZW5zZTE1" 294 | }, 295 | "forks": 0, 296 | "open_issues": 1, 297 | "watchers": 0, 298 | "default_branch": "master" 299 | } 300 | }, 301 | "_links": { 302 | "self": { 303 | "href": "https://api.github.com/repos/brndnmtthws/labhub/pulls/1" 304 | }, 305 | "html": { 306 | "href": "https://github.com/brndnmtthws/labhub/pull/1" 307 | }, 308 | "issue": { 309 | "href": "https://api.github.com/repos/brndnmtthws/labhub/issues/1" 310 | }, 311 | "comments": { 312 | "href": "https://api.github.com/repos/brndnmtthws/labhub/issues/1/comments" 313 | }, 314 | "review_comments": { 315 | "href": "https://api.github.com/repos/brndnmtthws/labhub/pulls/1/comments" 316 | }, 317 | "review_comment": { 318 | "href": "https://api.github.com/repos/brndnmtthws/labhub/pulls/comments{/number}" 319 | }, 320 | "commits": { 321 | "href": "https://api.github.com/repos/brndnmtthws/labhub/pulls/1/commits" 322 | }, 323 | "statuses": { 324 | "href": "https://api.github.com/repos/brndnmtthws/labhub/statuses/138f1ebf90d199f50f257ea5324f42a13fbc46df" 325 | } 326 | }, 327 | "author_association": "OWNER", 328 | "draft": false, 329 | "merged": false, 330 | "mergeable": null, 331 | "rebaseable": null, 332 | "mergeable_state": "unknown", 333 | "merged_by": null, 334 | "comments": 0, 335 | "review_comments": 0, 336 | "maintainer_can_modify": false, 337 | "commits": 1, 338 | "additions": 1, 339 | "deletions": 0, 340 | "changed_files": 1 341 | }, 342 | "repository": { 343 | "id": 172714879, 344 | "node_id": "MDEwOlJlcG9zaXRvcnkxNzI3MTQ4Nzk=", 345 | "name": "labhub", 346 | "full_name": "brndnmtthws/labhub", 347 | "private": true, 348 | "owner": { 349 | "login": "brndnmtthws", 350 | "id": 3129093, 351 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 352 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 353 | "gravatar_id": "", 354 | "url": "https://api.github.com/users/brndnmtthws", 355 | "html_url": "https://github.com/brndnmtthws", 356 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 357 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 358 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 359 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 360 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 361 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 362 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 363 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 364 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 365 | "type": "User", 366 | "site_admin": false 367 | }, 368 | "html_url": "https://github.com/brndnmtthws/labhub", 369 | "description": null, 370 | "fork": false, 371 | "url": "https://api.github.com/repos/brndnmtthws/labhub", 372 | "forks_url": "https://api.github.com/repos/brndnmtthws/labhub/forks", 373 | "keys_url": "https://api.github.com/repos/brndnmtthws/labhub/keys{/key_id}", 374 | "collaborators_url": "https://api.github.com/repos/brndnmtthws/labhub/collaborators{/collaborator}", 375 | "teams_url": "https://api.github.com/repos/brndnmtthws/labhub/teams", 376 | "hooks_url": "https://api.github.com/repos/brndnmtthws/labhub/hooks", 377 | "issue_events_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/events{/number}", 378 | "events_url": "https://api.github.com/repos/brndnmtthws/labhub/events", 379 | "assignees_url": "https://api.github.com/repos/brndnmtthws/labhub/assignees{/user}", 380 | "branches_url": "https://api.github.com/repos/brndnmtthws/labhub/branches{/branch}", 381 | "tags_url": "https://api.github.com/repos/brndnmtthws/labhub/tags", 382 | "blobs_url": "https://api.github.com/repos/brndnmtthws/labhub/git/blobs{/sha}", 383 | "git_tags_url": "https://api.github.com/repos/brndnmtthws/labhub/git/tags{/sha}", 384 | "git_refs_url": "https://api.github.com/repos/brndnmtthws/labhub/git/refs{/sha}", 385 | "trees_url": "https://api.github.com/repos/brndnmtthws/labhub/git/trees{/sha}", 386 | "statuses_url": "https://api.github.com/repos/brndnmtthws/labhub/statuses/{sha}", 387 | "languages_url": "https://api.github.com/repos/brndnmtthws/labhub/languages", 388 | "stargazers_url": "https://api.github.com/repos/brndnmtthws/labhub/stargazers", 389 | "contributors_url": "https://api.github.com/repos/brndnmtthws/labhub/contributors", 390 | "subscribers_url": "https://api.github.com/repos/brndnmtthws/labhub/subscribers", 391 | "subscription_url": "https://api.github.com/repos/brndnmtthws/labhub/subscription", 392 | "commits_url": "https://api.github.com/repos/brndnmtthws/labhub/commits{/sha}", 393 | "git_commits_url": "https://api.github.com/repos/brndnmtthws/labhub/git/commits{/sha}", 394 | "comments_url": "https://api.github.com/repos/brndnmtthws/labhub/comments{/number}", 395 | "issue_comment_url": "https://api.github.com/repos/brndnmtthws/labhub/issues/comments{/number}", 396 | "contents_url": "https://api.github.com/repos/brndnmtthws/labhub/contents/{+path}", 397 | "compare_url": "https://api.github.com/repos/brndnmtthws/labhub/compare/{base}...{head}", 398 | "merges_url": "https://api.github.com/repos/brndnmtthws/labhub/merges", 399 | "archive_url": "https://api.github.com/repos/brndnmtthws/labhub/{archive_format}{/ref}", 400 | "downloads_url": "https://api.github.com/repos/brndnmtthws/labhub/downloads", 401 | "issues_url": "https://api.github.com/repos/brndnmtthws/labhub/issues{/number}", 402 | "pulls_url": "https://api.github.com/repos/brndnmtthws/labhub/pulls{/number}", 403 | "milestones_url": "https://api.github.com/repos/brndnmtthws/labhub/milestones{/number}", 404 | "notifications_url": "https://api.github.com/repos/brndnmtthws/labhub/notifications{?since,all,participating}", 405 | "labels_url": "https://api.github.com/repos/brndnmtthws/labhub/labels{/name}", 406 | "releases_url": "https://api.github.com/repos/brndnmtthws/labhub/releases{/id}", 407 | "deployments_url": "https://api.github.com/repos/brndnmtthws/labhub/deployments", 408 | "created_at": "2019-02-26T13:16:44Z", 409 | "updated_at": "2019-03-01T17:35:50Z", 410 | "pushed_at": "2019-03-01T17:35:48Z", 411 | "git_url": "git://github.com/brndnmtthws/labhub.git", 412 | "ssh_url": "git@github.com:brndnmtthws/labhub.git", 413 | "clone_url": "https://github.com/brndnmtthws/labhub.git", 414 | "svn_url": "https://github.com/brndnmtthws/labhub", 415 | "homepage": null, 416 | "size": 130, 417 | "stargazers_count": 0, 418 | "watchers_count": 0, 419 | "language": "Rust", 420 | "has_issues": true, 421 | "has_projects": true, 422 | "has_downloads": true, 423 | "has_wiki": true, 424 | "has_pages": false, 425 | "forks_count": 0, 426 | "mirror_url": null, 427 | "archived": false, 428 | "open_issues_count": 1, 429 | "license": { 430 | "key": "unlicense", 431 | "name": "The Unlicense", 432 | "spdx_id": "Unlicense", 433 | "url": "https://api.github.com/licenses/unlicense", 434 | "node_id": "MDc6TGljZW5zZTE1" 435 | }, 436 | "forks": 0, 437 | "open_issues": 1, 438 | "watchers": 0, 439 | "default_branch": "master" 440 | }, 441 | "sender": { 442 | "login": "brndnmtthws", 443 | "id": 3129093, 444 | "node_id": "MDQ6VXNlcjMxMjkwOTM=", 445 | "avatar_url": "https://avatars1.githubusercontent.com/u/3129093?v=4", 446 | "gravatar_id": "", 447 | "url": "https://api.github.com/users/brndnmtthws", 448 | "html_url": "https://github.com/brndnmtthws", 449 | "followers_url": "https://api.github.com/users/brndnmtthws/followers", 450 | "following_url": "https://api.github.com/users/brndnmtthws/following{/other_user}", 451 | "gists_url": "https://api.github.com/users/brndnmtthws/gists{/gist_id}", 452 | "starred_url": "https://api.github.com/users/brndnmtthws/starred{/owner}{/repo}", 453 | "subscriptions_url": "https://api.github.com/users/brndnmtthws/subscriptions", 454 | "organizations_url": "https://api.github.com/users/brndnmtthws/orgs", 455 | "repos_url": "https://api.github.com/users/brndnmtthws/repos", 456 | "events_url": "https://api.github.com/users/brndnmtthws/events{/privacy}", 457 | "received_events_url": "https://api.github.com/users/brndnmtthws/received_events", 458 | "type": "User", 459 | "site_admin": false 460 | } 461 | } 462 | --------------------------------------------------------------------------------