From 32f5f761bc8e960293b4f4feaf973dd0da26d0f8 Mon Sep 17 00:00:00 2001 From: Mehmet Samet Duman Date: Sun, 5 Apr 2026 17:37:54 +0300 Subject: NOISSUE Project Tick Handbook is Released! Assisted-by: Claude:Opus-4.6-High Signed-off-by: Mehmet Samet Duman --- docs/handbook/ofborg/github-integration.md | 603 +++++++++++++++++++++++++++++ 1 file changed, 603 insertions(+) create mode 100644 docs/handbook/ofborg/github-integration.md (limited to 'docs/handbook/ofborg/github-integration.md') diff --git a/docs/handbook/ofborg/github-integration.md b/docs/handbook/ofborg/github-integration.md new file mode 100644 index 0000000000..4f33f77466 --- /dev/null +++ b/docs/handbook/ofborg/github-integration.md @@ -0,0 +1,603 @@ +# Tickborg — GitHub Integration + +## Overview + +Tickborg communicates with GitHub through the **GitHub App** model. A custom +fork of the `hubcaps` crate provides the Rust API client. Integration covers +webhook reception, commit statuses, check runs, issue/PR manipulation, and +comment posting. + +--- + +## GitHub App Authentication + +### `GithubAppVendingMachine` + +```rust +// config.rs +pub struct GithubAppVendingMachine { + conf: GithubAppConfig, + current_token: Option, + token_expiry: Option, +} +``` + +Handles two-stage GitHub App auth: + +1. **JWT**: Signed with the App's private RSA key, valid for up to 10 minutes. +2. **Installation token**: Obtained with the JWT, valid for ~1 hour. + +### Token Lifecycle + +```rust +impl GithubAppVendingMachine { + pub fn new(conf: GithubAppConfig) -> Self { + GithubAppVendingMachine { + conf, + current_token: None, + token_expiry: None, + } + } + + fn is_token_fresh(&self) -> bool { + match self.token_expiry { + Some(exp) => Instant::now() < exp, + None => false, + } + } + + pub async fn get_token(&mut self) -> Result { + if self.is_token_fresh() { + return Ok(self.current_token.clone().unwrap()); + } + // Generate a fresh JWT + let jwt = self.make_jwt()?; + // Exchange JWT for installation token + let client = hubcaps::Github::new( + "tickborg".to_owned(), + hubcaps::Credentials::Jwt(hubcaps::JwtToken::new(jwt)), + )?; + let installation = client.app() + .find_repo_installation(&self.conf.owner, &self.conf.repo) + .await?; + let token_result = client.app() + .create_installation_token(installation.id) + .await?; + + self.current_token = Some(token_result.token.clone()); + // Expire tokens 5 minutes early to avoid edge cases + self.token_expiry = Some( + Instant::now() + Duration::from_secs(55 * 60) - Duration::from_secs(5 * 60) + ); + + Ok(token_result.token) + } + + pub async fn github(&mut self) -> Result { + let token = self.get_token().await?; + Ok(hubcaps::Github::new( + "tickborg".to_owned(), + hubcaps::Credentials::Token(token), + )?) + } +} +``` + +### JWT Generation + +```rust +fn make_jwt(&self) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH).unwrap() + .as_secs() as i64; + + let payload = json!({ + "iat": now - 60, // 1 minute in the past (clock skew) + "exp": now + (10 * 60), // 10 minutes from now + "iss": self.conf.app_id, + }); + + let key = EncodingKey::from_rsa_pem( + &std::fs::read(&self.conf.private_key_file)? + )?; + + encode(&Header::new(Algorithm::RS256), &payload, &key) + .map_err(|e| format!("JWT encoding error: {}", e)) +} +``` + +### `GithubAppConfig` + +```rust +#[derive(Deserialize, Debug)] +pub struct GithubAppConfig { + pub app_id: u64, + pub private_key_file: PathBuf, + pub owner: String, + pub repo: String, + pub installation_id: Option, +} +``` + +--- + +## GitHub App Configuration + +The `GithubAppConfig` is nested under the top-level `Config`: + +```json +{ + "github_app": { + "app_id": 12345, + "private_key_file": "/etc/tickborg/private-key.pem", + "owner": "project-tick", + "repo": "Project-Tick", + "installation_id": 67890 + } +} +``` + +--- + +## Commit Statuses + +### `CommitStatus` + +```rust +// commitstatus.rs +pub struct CommitStatus { + api: hubcaps::statuses::Statuses, + sha: String, + context: String, + description: String, + url: Option, +} +``` + +### State Machine + +```rust +impl CommitStatus { + pub fn new( + statuses: hubcaps::statuses::Statuses, + sha: String, + context: String, + description: String, + url: Option, + ) -> Self; + + pub async fn set_url(&mut self, url: Option); + + pub async fn set_with_description( + &mut self, + description: &str, + state: hubcaps::statuses::State, + ) -> Result<(), CommitStatusError> { + self.description = description.to_owned(); + self.send_status(state).await + } + + pub async fn set( + &mut self, + state: hubcaps::statuses::State, + ) -> Result<(), CommitStatusError>; + + async fn send_status( + &self, + state: hubcaps::statuses::State, + ) -> Result<(), CommitStatusError> { + let options = hubcaps::statuses::StatusOptions::builder(state) + .description(&self.description) + .context(&self.context); + + let options = match &self.url { + Some(u) => options.target_url(u).build(), + None => options.build(), + }; + + self.api.create(&self.sha, &options) + .await + .map_err(|e| CommitStatusError::from(e))?; + + Ok(()) + } +} +``` + +### Error Classification + +```rust +#[derive(Debug)] +pub enum CommitStatusError { + ExpiredCreds(String), // GitHub App token expired + MissingSha(String), // Commit was force-pushed away + InternalError(String), // 5xx from GitHub API + Error(String), // Other errors +} +``` + +Error mapping from HTTP response: + +| HTTP Status | CommitStatusError Variant | Worker Action | +|------------|--------------------------|---------------| +| 401 | `ExpiredCreds` | `NackRequeue` (retry) | +| 422 ("No commit found") | `MissingSha` | `Ack` (skip) | +| 500-599 | `InternalError` | `NackRequeue` (retry) | +| Other | `Error` | `Ack` + add error label | + +--- + +## Check Runs + +### `job_to_check()` + +Creates a Check Run when a build job is started: + +```rust +pub async fn job_to_check( + github: &hubcaps::Github, + repo_full_name: &str, + job: &BuildJob, + runner_identity: &str, +) -> Result<(), String> { + let (owner, repo) = parse_repo_name(repo_full_name); + let checks = github.repo(owner, repo).check_runs(); + + checks.create(&hubcaps::checks::CheckRunOptions { + name: format!("build-{}-{}", job.project, job.system), + head_sha: job.pr.head_sha.clone(), + status: Some(hubcaps::checks::CheckRunStatus::InProgress), + external_id: Some(format!("{runner_identity}")), + started_at: Some(Utc::now()), + output: Some(hubcaps::checks::Output { + title: format!("Building {} on {}", job.project, job.system), + summary: format!("Runner: {runner_identity}"), + text: None, + annotations: vec![], + }), + ..Default::default() + }).await.map_err(|e| format!("Failed to create check run: {e}"))?; + + Ok(()) +} +``` + +### `result_to_check()` + +Updates a Check Run when a build completes: + +```rust +pub async fn result_to_check( + github: &hubcaps::Github, + repo_full_name: &str, + result: &BuildResult, +) -> Result<(), String> { + let (owner, repo) = parse_repo_name(repo_full_name); + let checks = github.repo(owner, repo).check_runs(); + + let conclusion = match &result.status { + BuildStatus::Success => hubcaps::checks::Conclusion::Success, + BuildStatus::Failure => hubcaps::checks::Conclusion::Failure, + BuildStatus::TimedOut => hubcaps::checks::Conclusion::TimedOut, + BuildStatus::Skipped => hubcaps::checks::Conclusion::Skipped, + BuildStatus::UnexpectedError { .. } => hubcaps::checks::Conclusion::Failure, + }; + + // Find and update the existing check run + // ... +} +``` + +--- + +## GitHub Event Types (ghevent) + +### Common Types + +```rust +// ghevent/common.rs +#[derive(Deserialize, Debug)] +pub struct GenericWebhook { + pub repository: Repository, +} + +#[derive(Deserialize, Debug)] +pub struct Repository { + pub owner: User, + pub name: String, + pub full_name: String, + pub clone_url: String, +} + +#[derive(Deserialize, Debug)] +pub struct User { + pub login: String, + pub id: u64, +} + +#[derive(Deserialize, Debug)] +pub struct Comment { + pub id: u64, + pub body: String, + pub user: User, +} + +#[derive(Deserialize, Debug)] +pub struct Issue { + pub number: u64, + pub title: String, + pub state: String, + pub user: User, + pub labels: Vec