Browse Source

Automate documentation updates for rust-lang/rust

Eric Huss 2 years ago
parent
commit
9c29701455
7 changed files with 707 additions and 5 deletions
  1. 108 1
      github-graphql/src/lib.rs
  2. 1 1
      src/db.rs
  3. 403 1
      src/github.rs
  4. 1 0
      src/handlers.rs
  5. 190 0
      src/handlers/docs_update.rs
  6. 2 1
      src/handlers/jobs.rs
  7. 2 1
      src/jobs.rs

+ 108 - 1
github-graphql/src/lib.rs

@@ -2,7 +2,7 @@
 //!
 //! See <https://docs.github.com/en/graphql> for more GitHub's GraphQL API.
 
-// This schema can be downloaded from https://docs.github.com/en/graphql/overview/public-schema
+// This schema can be downloaded from https://docs.github.com/public/schema.docs.graphql
 #[cynic::schema_for_derives(file = "src/github.graphql", module = "schema")]
 pub mod queries {
     use super::schema;
@@ -155,6 +155,113 @@ pub mod queries {
     pub struct Uri(pub String);
 }
 
+#[cynic::schema_for_derives(file = "src/github.graphql", module = "schema")]
+pub mod docs_update_queries {
+    use super::queries::{DateTime, PageInfo};
+    use super::schema;
+
+    #[derive(cynic::FragmentArguments, Debug)]
+    pub struct RecentCommitsArguments {
+        pub branch: String,
+        pub name: String,
+        pub owner: String,
+        pub after: Option<String>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    #[cynic(graphql_type = "Query", argument_struct = "RecentCommitsArguments")]
+    pub struct RecentCommits {
+        #[arguments(name = &args.name, owner = &args.owner)]
+        pub repository: Option<Repository>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    #[cynic(argument_struct = "RecentCommitsArguments")]
+    pub struct Repository {
+        #[arguments(qualified_name = &args.branch)]
+        #[cynic(rename = "ref")]
+        pub ref_: Option<Ref>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct Ref {
+        pub target: Option<GitObject>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct Commit {
+        #[arguments(first = 100)]
+        pub history: CommitHistoryConnection,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct CommitHistoryConnection {
+        pub total_count: i32,
+        pub page_info: PageInfo,
+        pub nodes: Option<Vec<Option<Commit2>>>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    #[cynic(graphql_type = "Commit")]
+    pub struct Commit2 {
+        pub oid: GitObjectID,
+        #[arguments(first = 1)]
+        pub parents: CommitConnection,
+        pub committed_date: DateTime,
+        pub message_headline: String,
+        #[arguments(first = 1)]
+        pub associated_pull_requests: Option<PullRequestConnection>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct PullRequestConnection {
+        pub nodes: Option<Vec<Option<PullRequest>>>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct PullRequest {
+        pub number: i32,
+        pub title: String,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct CommitConnection {
+        pub nodes: Option<Vec<Option<Commit3>>>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    #[cynic(graphql_type = "Commit")]
+    pub struct Commit3 {
+        pub oid: GitObjectID,
+    }
+
+    #[derive(cynic::InlineFragments, Debug)]
+    pub enum GitObject {
+        Commit(Commit),
+        // These three variants are here just to pacify cynic. I don't know
+        // why it fails to compile without them.
+        Tree(Tree),
+        Tag(Tag),
+        Blob(Blob),
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct Tree {
+        pub id: cynic::Id,
+    }
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct Tag {
+        pub id: cynic::Id,
+    }
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct Blob {
+        pub id: cynic::Id,
+    }
+
+    #[derive(cynic::Scalar, Debug, Clone)]
+    pub struct GitObjectID(pub String);
+}
+
 mod schema {
     cynic::use_schema!("src/github.graphql");
 }

+ 1 - 1
src/db.rs

@@ -211,7 +211,7 @@ pub async fn run_scheduled_jobs(db: &DbClient) -> anyhow::Result<()> {
                 delete_job(&db, &job.id).await?;
             }
             Err(e) => {
-                tracing::trace!("job failed on execution (id={:?}, error={:?})", job.id, e);
+                tracing::error!("job failed on execution (id={:?}, error={:?})", job.id, e);
                 update_job_error_message(&db, &job.id, &e.to_string()).await?;
             }
         }

+ 403 - 1
src/github.rs

@@ -6,7 +6,7 @@ use hyper::header::HeaderValue;
 use once_cell::sync::OnceCell;
 use reqwest::header::{AUTHORIZATION, USER_AGENT};
 use reqwest::{Client, Request, RequestBuilder, Response, StatusCode};
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 use std::convert::TryInto;
 use std::{
     fmt,
@@ -1007,6 +1007,10 @@ impl Repository {
     const GITHUB_API_URL: &'static str = "https://api.github.com";
     const GITHUB_GRAPHQL_API_URL: &'static str = "https://api.github.com/graphql";
 
+    fn url(&self) -> String {
+        format!("{}/repos/{}", Repository::GITHUB_API_URL, self.full_name)
+    }
+
     pub fn owner(&self) -> &str {
         self.full_name.split_once('/').unwrap().0
     }
@@ -1180,6 +1184,321 @@ impl Repository {
             ordering.page,
         )
     }
+
+    /// Retrieves a git commit for the given SHA.
+    pub async fn git_commit(&self, client: &GithubClient, sha: &str) -> anyhow::Result<GitCommit> {
+        let url = format!("{}/git/commits/{sha}", self.url());
+        client
+            .json(client.get(&url))
+            .await
+            .with_context(|| format!("{} failed to get git commit {sha}", self.full_name))
+    }
+
+    /// Creates a new commit.
+    pub async fn create_commit(
+        &self,
+        client: &GithubClient,
+        message: &str,
+        parents: &[&str],
+        tree: &str,
+    ) -> anyhow::Result<GitCommit> {
+        let url = format!("{}/git/commits", self.url());
+        client
+            .json(client.post(&url).json(&serde_json::json!({
+                "message": message,
+                "parents": parents,
+                "tree": tree,
+            })))
+            .await
+            .with_context(|| format!("{} failed to create commit for tree {tree}", self.full_name))
+    }
+
+    /// Retrieves a git reference for the given refname.
+    pub async fn get_reference(
+        &self,
+        client: &GithubClient,
+        refname: &str,
+    ) -> anyhow::Result<GitReference> {
+        let url = format!("{}/git/ref/{}", self.url(), refname);
+        client
+            .json(client.get(&url))
+            .await
+            .with_context(|| format!("{} failed to get git reference {refname}", self.full_name))
+    }
+
+    /// Updates an existing git reference to a new SHA.
+    pub async fn update_reference(
+        &self,
+        client: &GithubClient,
+        refname: &str,
+        sha: &str,
+    ) -> anyhow::Result<()> {
+        let url = format!("{}/git/refs/{}", self.url(), refname);
+        client
+            ._send_req(client.patch(&url).json(&serde_json::json!({
+                "sha": sha,
+                "force": true,
+            })))
+            .await
+            .with_context(|| {
+                format!(
+                    "{} failed to update reference {refname} to {sha}",
+                    self.full_name
+                )
+            })?;
+        Ok(())
+    }
+
+    /// Returns a list of recent commits on the given branch.
+    ///
+    /// Returns results in the OID range `oldest` (exclusive) to `newest`
+    /// (inclusive).
+    pub async fn recent_commits(
+        &self,
+        client: &GithubClient,
+        branch: &str,
+        oldest: &str,
+        newest: &str,
+    ) -> anyhow::Result<Vec<RecentCommit>> {
+        // This is used to deduplicate the results (so that a PR with multiple
+        // commits will only show up once).
+        let mut prs_seen = HashSet::new();
+        let mut recent_commits = Vec::new(); // This is the final result.
+        use cynic::QueryBuilder;
+        use github_graphql::docs_update_queries::{
+            GitObject, RecentCommits, RecentCommitsArguments,
+        };
+
+        let mut args = RecentCommitsArguments {
+            branch: branch.to_string(),
+            name: self.name().to_string(),
+            owner: self.owner().to_string(),
+            after: None,
+        };
+        let mut found_newest = false;
+        let mut found_oldest = false;
+        // This simulates --first-parent. We only care about top-level commits.
+        // Unfortunately the GitHub API doesn't provide anything like that.
+        let mut next_first_parent = None;
+        // Search for `oldest` within 3 pages (300 commits).
+        for _ in 0..3 {
+            let query = RecentCommits::build(&args);
+            let response = client
+                .json(client.post(Repository::GITHUB_GRAPHQL_API_URL).json(&query))
+                .await
+                .with_context(|| {
+                    format!(
+                        "{} failed to get recent commits branch={branch}",
+                        self.full_name
+                    )
+                })?;
+            let data: cynic::GraphQlResponse<RecentCommits> = query
+                .decode_response(response)
+                .with_context(|| format!("failed to parse response for `RecentCommits`"))?;
+            if let Some(errors) = data.errors {
+                anyhow::bail!("There were graphql errors. {:?}", errors);
+            }
+            let target = data
+                .data
+                .ok_or_else(|| anyhow::anyhow!("No data returned."))?
+                .repository
+                .ok_or_else(|| anyhow::anyhow!("No repository."))?
+                .ref_
+                .ok_or_else(|| anyhow::anyhow!("No ref."))?
+                .target
+                .ok_or_else(|| anyhow::anyhow!("No target."))?;
+            let commit = match target {
+                GitObject::Commit(commit) => commit,
+                _ => anyhow::bail!("unexpected target type {target:?}"),
+            };
+            let commits = commit
+                .history
+                .nodes
+                .ok_or_else(|| anyhow::anyhow!("No history."))?
+                .into_iter()
+                .filter_map(|node| node)
+                // Don't include anything newer than `newest`
+                .skip_while(|node| {
+                    if found_newest || node.oid.0 == newest {
+                        found_newest = true;
+                        false
+                    } else {
+                        // This should only happen if there is a commit that arrives
+                        // between the time that `update_submodules` fetches the latest
+                        // ref, and this runs. This window should be a few seconds, so it
+                        // should be unlikely. This warning is here in case my assumptions
+                        // about how things work is not correct.
+                        tracing::warn!(
+                            "unexpected race with submodule history, newest oid={newest} skipping oid={}",
+                            node.oid.0
+                        );
+                        true
+                    }
+                })
+                // Skip nodes that aren't the first parent
+                .filter(|node| {
+                    let this_first_parent = node.parents.nodes
+                        .as_ref()
+                        // Grab the first parent
+                        .and_then(|nodes| nodes.first())
+                        // Strip away the useless Option
+                        .and_then(|parent_opt| parent_opt.as_ref())
+                        .map(|parent| parent.oid.0.clone());
+
+                    match &next_first_parent {
+                        Some(first_parent) => {
+                            if first_parent == &node.oid.0 {
+                                // Found the next first parent, include it and
+                                // set next_first_parent to look for this
+                                // commit's first parent.
+                                next_first_parent = this_first_parent;
+                                true
+                            } else {
+                                // Still looking for the next first parent.
+                                false
+                            }
+                        }
+                        None => {
+                            // First commit.
+                            next_first_parent = this_first_parent;
+                            true
+                        }
+                    }
+                })
+                // Stop once reached the `oldest` commit
+                .take_while(|node| {
+                    if node.oid.0 == oldest {
+                        found_oldest = true;
+                        false
+                    } else {
+                        true
+                    }
+                })
+                .filter_map(|node| {
+                    // Determine if this is associated with a PR or not.
+                    match node.associated_pull_requests
+                        // Strip away the useless Option
+                        .and_then(|pr| pr.nodes)
+                        // Get the first PR (we only care about one)
+                        .and_then(|mut nodes| nodes.pop())
+                        // Strip away the useless Option
+                        .flatten() {
+                        Some(pr) => {
+                            // Only include a PR once
+                            if prs_seen.insert(pr.number) {
+                                Some(RecentCommit {
+                                    pr_num: Some(pr.number),
+                                    title: pr.title,
+                                    oid: node.oid.0.clone(),
+                                    committed_date: node.committed_date,
+                                })
+                            } else {
+                                None
+                            }
+                        }
+                        None => {
+                            // This is an unassociated commit, possibly
+                            // created without a PR.
+                            Some(RecentCommit {
+                                pr_num: None,
+                                title: node.message_headline,
+                                oid: node.oid.0,
+                                committed_date: node.committed_date,
+                            })
+                        }
+                    }
+                });
+            recent_commits.extend(commits);
+            let page_info = commit.history.page_info;
+            if found_oldest || !page_info.has_next_page || page_info.end_cursor.is_none() {
+                break;
+            }
+            args.after = page_info.end_cursor;
+        }
+        if !found_oldest {
+            // This should probably do something more than log a warning, but
+            // I don't think it is too important at this time (the log message
+            // is only informational, and this should be unlikely to happen).
+            tracing::warn!(
+                "{} failed to find oldest commit sha={oldest} branch={branch}",
+                self.full_name
+            );
+        }
+        Ok(recent_commits)
+    }
+
+    /// Creates a new git tree based on another tree.
+    pub async fn update_tree(
+        &self,
+        client: &GithubClient,
+        base_tree: &str,
+        tree: &[GitTreeEntry],
+    ) -> anyhow::Result<GitTreeObject> {
+        let url = format!("{}/git/trees", self.url());
+        client
+            .json(client.post(&url).json(&serde_json::json!({
+                "base_tree": base_tree,
+                "tree": tree,
+            })))
+            .await
+            .with_context(|| {
+                format!(
+                    "{} failed to update tree with base {base_tree}",
+                    self.full_name
+                )
+            })
+    }
+
+    /// Returns information about the git submodule at the given path.
+    ///
+    /// `refname` is the ref to use for fetching information. If `None`, will
+    /// use the latest version on the default branch.
+    pub async fn submodule(
+        &self,
+        client: &GithubClient,
+        path: &str,
+        refname: Option<&str>,
+    ) -> anyhow::Result<Submodule> {
+        let mut url = format!("{}/contents/{}", self.url(), path);
+        if let Some(refname) = refname {
+            url.push_str("?ref=");
+            url.push_str(refname);
+        }
+        client.json(client.get(&url)).await.with_context(|| {
+            format!(
+                "{} failed to get submodule path={path} refname={refname:?}",
+                self.full_name
+            )
+        })
+    }
+
+    /// Creates a new PR.
+    pub async fn new_pr(
+        &self,
+        client: &GithubClient,
+        title: &str,
+        head: &str,
+        base: &str,
+        body: &str,
+    ) -> anyhow::Result<Issue> {
+        let url = format!("{}/pulls", self.url());
+        let mut issue: Issue = client
+            .json(client.post(&url).json(&serde_json::json!({
+                "title": title,
+                "head": head,
+                "base": base,
+                "body": body,
+            })))
+            .await
+            .with_context(|| {
+                format!(
+                    "{} failed to create a new PR head={head} base={base} title={title}",
+                    self.full_name
+                )
+            })?;
+        issue.pull_request = Some(PullRequestDetails {});
+        Ok(issue)
+    }
 }
 
 pub struct Query<'a> {
@@ -1577,6 +1896,16 @@ impl GithubClient {
             }
         }
     }
+
+    /// Returns information about a repository.
+    ///
+    /// The `full_name` should be something like `rust-lang/rust`.
+    pub async fn repository(&self, full_name: &str) -> anyhow::Result<Repository> {
+        let req = self.get(&format!("{}/repos/{full_name}", Repository::GITHUB_API_URL));
+        self.json(req)
+            .await
+            .with_context(|| format!("{} failed to get repo", full_name))
+    }
 }
 
 #[derive(Debug, serde::Deserialize)]
@@ -1588,8 +1917,36 @@ pub struct GithubCommit {
 
 #[derive(Debug, serde::Deserialize)]
 pub struct GitCommit {
+    pub sha: String,
     pub author: GitUser,
     pub message: String,
+    pub tree: GitCommitTree,
+}
+
+#[derive(Debug, serde::Deserialize)]
+pub struct GitCommitTree {
+    pub sha: String,
+}
+
+#[derive(Debug, serde::Deserialize)]
+pub struct GitTreeObject {
+    pub sha: String,
+}
+
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+pub struct GitTreeEntry {
+    pub path: String,
+    pub mode: String,
+    #[serde(rename = "type")]
+    pub object_type: String,
+    pub sha: String,
+}
+
+pub struct RecentCommit {
+    pub title: String,
+    pub pr_num: Option<i32>,
+    pub oid: String,
+    pub committed_date: DateTime<Utc>,
 }
 
 #[derive(Debug, serde::Deserialize)]
@@ -1783,6 +2140,51 @@ impl IssuesQuery for LeastRecentlyReviewedPullRequests {
     }
 }
 
+#[derive(Debug, serde::Deserialize)]
+pub struct GitReference {
+    #[serde(rename = "ref")]
+    pub refname: String,
+    pub object: GitObject,
+}
+
+#[derive(Debug, serde::Deserialize)]
+pub struct GitObject {
+    #[serde(rename = "type")]
+    pub object_type: String,
+    pub sha: String,
+    pub url: String,
+}
+
+#[derive(Debug, serde::Deserialize)]
+pub struct Submodule {
+    pub name: String,
+    pub path: String,
+    pub sha: String,
+    pub submodule_git_url: String,
+}
+
+impl Submodule {
+    /// Returns the `Repository` this submodule points to.
+    ///
+    /// This assumes that the submodule is on GitHub.
+    pub async fn repository(&self, client: &GithubClient) -> anyhow::Result<Repository> {
+        let fullname = self
+            .submodule_git_url
+            .strip_prefix("https://github.com/")
+            .ok_or_else(|| {
+                anyhow::anyhow!(
+                    "only github submodules supported, got {}",
+                    self.submodule_git_url
+                )
+            })?
+            .strip_suffix(".git")
+            .ok_or_else(|| {
+                anyhow::anyhow!("expected .git suffix, got {}", self.submodule_git_url)
+            })?;
+        client.repository(fullname).await
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

+ 1 - 0
src/handlers.rs

@@ -26,6 +26,7 @@ impl fmt::Display for HandlerError {
 mod assign;
 mod autolabel;
 mod close;
+pub mod docs_update;
 mod github_releases;
 mod glacier;
 pub mod jobs;

+ 190 - 0
src/handlers/docs_update.rs

@@ -0,0 +1,190 @@
+//! A scheduled job to post a PR to update the documentation on rust-lang/rust.
+
+use crate::db::jobs::JobSchedule;
+use crate::github::{self, GitTreeEntry, GithubClient, Repository};
+use anyhow::Context;
+use anyhow::Result;
+use cron::Schedule;
+use reqwest::Client;
+use std::fmt::Write;
+use std::str::FromStr;
+
+const BRANCH_NAME: &str = "docs-update";
+
+const SUBMODULES: &[&str] = &[
+    "src/doc/book",
+    "src/doc/edition-guide",
+    "src/doc/embedded-book",
+    "src/doc/nomicon",
+    "src/doc/reference",
+    "src/doc/rust-by-example",
+    "src/doc/rustc-dev-guide",
+];
+
+const TITLE: &str = "Update books";
+
+pub fn job() -> JobSchedule {
+    JobSchedule {
+        name: "docs_update".to_string(),
+        // Around 9am Pacific time on every Monday.
+        schedule: Schedule::from_str("0 00 17 * * Mon *").unwrap(),
+        metadata: serde_json::Value::Null,
+    }
+}
+
+pub async fn handle_job() -> Result<()> {
+    // Only run every other week. Doing it every week can be a bit noisy, and
+    // (rarely) a PR can take longer than a week to merge (like if there are
+    // CI issues). `Schedule` does not allow expressing this, so check it
+    // manually.
+    //
+    // This is set to run the first week after a release, and the week just
+    // before a release. That allows getting the latest changes in the next
+    // release, accounting for possibly taking a few days for the PR to land.
+    let today = chrono::Utc::today().naive_utc();
+    let base = chrono::naive::NaiveDate::from_ymd(2015, 12, 10);
+    let duration = today.signed_duration_since(base);
+    let weeks = duration.num_weeks();
+    if weeks % 2 != 0 {
+        tracing::trace!("skipping job, this is an odd week");
+        return Ok(());
+    }
+
+    tracing::trace!("starting docs-update");
+    docs_update().await.context("failed to process docs update")
+}
+
+async fn docs_update() -> Result<()> {
+    let gh = GithubClient::new_with_default_token(Client::new());
+    let repo = gh.repository("rust-lang/rust").await?;
+
+    let updates = get_submodule_updates(&gh, &repo).await?;
+    if updates.is_empty() {
+        tracing::trace!("no updates this week?");
+        return Ok(());
+    }
+
+    create_commit(&gh, &repo, &updates).await?;
+    create_pr(&gh, &repo, &updates).await?;
+    Ok(())
+}
+
+struct Update {
+    path: String,
+    new_hash: String,
+    pr_body: String,
+}
+
+async fn get_submodule_updates(
+    gh: &GithubClient,
+    repo: &github::Repository,
+) -> Result<Vec<Update>> {
+    let mut updates = Vec::new();
+    for submodule_path in SUBMODULES {
+        tracing::trace!("checking submodule {submodule_path}");
+        let submodule = repo.submodule(gh, submodule_path, None).await?;
+        let submodule_repo = submodule.repository(gh).await?;
+        let latest_commit = submodule_repo
+            .get_reference(gh, &format!("heads/{}", submodule_repo.default_branch))
+            .await?;
+        if submodule.sha == latest_commit.object.sha {
+            tracing::trace!(
+                "skipping submodule {submodule_path}, no changes sha={}",
+                submodule.sha
+            );
+            continue;
+        }
+        let current_hash = submodule.sha;
+        let new_hash = latest_commit.object.sha;
+        let pr_body = generate_pr_body(gh, &submodule_repo, &current_hash, &new_hash).await?;
+
+        let update = Update {
+            path: submodule.path,
+            new_hash,
+            pr_body,
+        };
+        updates.push(update);
+    }
+    Ok(updates)
+}
+
+async fn generate_pr_body(
+    gh: &GithubClient,
+    repo: &github::Repository,
+    oldest: &str,
+    newest: &str,
+) -> Result<String> {
+    let recent_commits: Vec<_> = repo
+        .recent_commits(gh, &repo.default_branch, oldest, newest)
+        .await?;
+    if recent_commits.is_empty() {
+        anyhow::bail!(
+            "unexpected empty set of commits for {} oldest={oldest} newest={newest}",
+            repo.full_name
+        );
+    }
+    let mut body = format!(
+        "## {}\n\
+        \n\
+        {} commits in {}..{}\n\
+        {} to {}\n\
+        \n",
+        repo.full_name,
+        recent_commits.len(),
+        oldest,
+        newest,
+        recent_commits.first().unwrap().committed_date,
+        recent_commits.last().unwrap().committed_date,
+    );
+    for commit in recent_commits {
+        write!(body, "- {}", commit.title).unwrap();
+        if let Some(num) = commit.pr_num {
+            write!(body, " ({}#{})", repo.full_name, num).unwrap();
+        }
+        body.push('\n');
+    }
+    Ok(body)
+}
+
+async fn create_commit(
+    gh: &GithubClient,
+    rust_repo: &Repository,
+    updates: &[Update],
+) -> Result<()> {
+    let master_ref = rust_repo
+        .get_reference(gh, &format!("heads/{}", rust_repo.default_branch))
+        .await?;
+    let master_commit = rust_repo.git_commit(gh, &master_ref.object.sha).await?;
+    let tree_entries: Vec<_> = updates
+        .iter()
+        .map(|update| GitTreeEntry {
+            path: update.path.clone(),
+            mode: "160000".to_string(),
+            object_type: "commit".to_string(),
+            sha: update.new_hash.clone(),
+        })
+        .collect();
+    let new_tree = rust_repo
+        .update_tree(gh, &master_commit.tree.sha, &tree_entries)
+        .await?;
+    let commit = rust_repo
+        .create_commit(gh, TITLE, &[&master_ref.object.sha], &new_tree.sha)
+        .await?;
+    rust_repo
+        .update_reference(gh, &format!("heads/{BRANCH_NAME}"), &commit.sha)
+        .await?;
+    Ok(())
+}
+
+async fn create_pr(gh: &GithubClient, rust_repo: &Repository, updates: &[Update]) -> Result<()> {
+    let mut body = String::new();
+    for update in updates {
+        write!(body, "{}\n", update.pr_body).unwrap();
+    }
+
+    let pr = rust_repo
+        .new_pr(gh, TITLE, BRANCH_NAME, &rust_repo.default_branch, &body)
+        .await?;
+    tracing::debug!("created PR {}", pr.html_url);
+    Ok(())
+}

+ 2 - 1
src/handlers/jobs.rs

@@ -5,7 +5,8 @@
 // Further info could be find in src/jobs.rs
 
 pub async fn handle_job(name: &String, metadata: &serde_json::Value) -> anyhow::Result<()> {
-    match name {
+    match name.as_str() {
+        "docs_update" => super::docs_update::handle_job().await,
         _ => default(&name, &metadata),
     }
 }

+ 2 - 1
src/jobs.rs

@@ -45,7 +45,8 @@ pub const JOB_PROCESSING_CADENCE_IN_SECS: u64 = 60;
 
 pub fn jobs() -> Vec<JobSchedule> {
     // Add to this vector any new cron task you want (as explained above)
-    let jobs: Vec<JobSchedule> = Vec::new();
+    let mut jobs: Vec<JobSchedule> = Vec::new();
+    jobs.push(crate::handlers::docs_update::job());
 
     jobs
 }