FEATURE: Pull Request Syncing

FEATURE: Pull Request Syncing

diff --git a/app/controllers/discourse_code_review/code_review_controller.rb b/app/controllers/discourse_code_review/code_review_controller.rb
index 2567632..0cad05d 100644
--- a/app/controllers/discourse_code_review/code_review_controller.rb
+++ b/app/controllers/discourse_code_review/code_review_controller.rb
@@ -48,6 +48,24 @@ module DiscourseCodeReview
         end
       end
 
+      if type == "commit_comment"
+        syncer = DiscourseCodeReview.github_pr_syncer
+        git_commit = params["comment"]["commit_id"]
+
+        syncer.sync_associated_pull_requests(repo_name, git_commit)
+      end
+
+      if ["pull_request", "issue_comment", "pull_request_review", "pull_request_review_comment"].include? type
+        syncer = DiscourseCodeReview.github_pr_syncer
+
+        issue_number =
+          params['number'] ||
+          (params['issue'] && params['issue']['number']) ||
+          (params['pull_request'] && params['pull_request']['number'])
+
+        syncer.sync_pull_request(repo_name, issue_number)
+      end
+
       render plain: '"ok"'
     end
 
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index af7a501..7501558 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -4,6 +4,8 @@ en:
       approved: "Approved"
       followup: "Follow Up"
       followed_up: "Followed Up"
+      merged: "Merged"
+      renamed: "Renamed"
     notifications:
       code_review:
         commit_approved:
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 1f896ee..8b26958 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -9,6 +9,8 @@ en:
     code_review_followup_tag: 'Tag to apply to follow up commits'
     code_review_approved_tag: 'Tag to apply to approved commits'
     code_review_unmerged_tag: 'Tag to apply to unmerged commits'
+    code_review_commit_tag: 'Tag to apply to commit topics'
+    code_review_pull_request_tag: 'Tag to apply to pull request topics'
     code_review_github_webhook_secret: 'web hook secret string to use use for https://sitename/code-review/webhook'
     code_review_allow_self_approval: 'Allow self approval of commits'
     code_review_auto_assign_on_followup: 'Automatically assign topic to author on followup'
diff --git a/config/settings.yml b/config/settings.yml
index 1c39cd1..64141d7 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -14,6 +14,8 @@ plugins:
     client: true
     default: "follow-up"
   code_review_unmerged_tag: "unmerged"
+  code_review_commit_tag: "commit"
+  code_review_pull_request_tag: "pull-request"
   code_review_github_webhook_secret: ""
   code_review_allow_self_approval:
     client: true
diff --git a/lib/discourse_code_review/github_category_syncer.rb b/lib/discourse_code_review/github_category_syncer.rb
index 1e16edc..13386cc 100644
--- a/lib/discourse_code_review/github_category_syncer.rb
+++ b/lib/discourse_code_review/github_category_syncer.rb
@@ -39,6 +39,16 @@ module DiscourseCodeReview
           .each(&blk)
       end
 
+      def github_repo_category_fields
+        CategoryCustomField
+          .where(name: GithubRepoName)
+          .include(:category)
+      end
+
+      def get_repo_name_from_topic(topic)
+        topic.category.custom_fields[GithubRepoName]
+      end
+
       private
 
       def find_category_name(name)
diff --git a/lib/discourse_code_review/github_pr_querier.rb b/lib/discourse_code_review/github_pr_querier.rb
new file mode 100644
index 0000000..75e76ef
--- /dev/null
+++ b/lib/discourse_code_review/github_pr_querier.rb
@@ -0,0 +1,471 @@
+# frozen_string_literal: true
+
+module DiscourseCodeReview
+  CommitThread =
+    TypedData::TypedStruct.new(
+      github_id: String,
+      actor: Actor,
+      created_at: Time,
+      commit_sha: String
+    )
+
+  class GithubPRQuerier
+    def initialize(graphql_client)
+      @graphql_client = graphql_client
+    end
+
+    def first_review_thread_comment_database_id(review_thread_id)
+      response =
+        graphql_client.execute("
+          query {
+            node(id: #{review_thread_id.to_json}) {
+              ... on PullRequestReviewThread {
+                comments(first: 1) {
+                  nodes {
+                    databaseId
+                  }
+                }
+              }
+            }
+          }
+        ")
+
+      comment_id = response[:node][:comments][:nodes][0][:databaseId]
+      raise "Expected Integer, but got #{comment_id.class}" unless Integer === comment_id
+
+      comment_id
+    end
+
+    def first_review_thread_comment(review_thread)
+      response =
+        graphql_client.execute("
+          query {
+            node(id: #{review_thread.github_id.to_json}) {
+              ... on PullRequestReviewThread {
+                comments(first: 1) {
+                  nodes {
+                    id,
+                    createdAt,
+                    author {
+                      login
+                    },
+                    body,
+                    diffHunk,
+                    path
+                  }
+                }
+              }
+            }
+          }
+        ")
+
+      comment = response[:node][:comments][:nodes][0]
+
+      event_info =
+        PullRequestEventInfo.new(
+          actor:
+            Actor.new(
+              github_login: comment[:author][:login]
+            ),
+          github_id: comment[:id],
+          created_at: Time.parse(comment[:createdAt]),
+        )
+
+      diff_hunk = comment[:diffHunk]
+      path = comment[:path]
+      context =
+        if diff_hunk.present? && path.present?
+          CommentContext.new(
+            diff_hunk: diff_hunk,
+            path: path
+          )
+        end
+
+      event =
+        PullRequestEvent.create(
+          :review_thread_started,
+          body: comment[:body],
+          context: context,
+          thread: CommentThread.new(github_id: review_thread.github_id)
+        )
+
+      [event_info, event]
+    end
+
+    def subsequent_review_thread_comments(review_thread)
+      comments =
+        graphql_client.paginated_query do |execute, cursor|
+          query = "
+            query {
+              node(id: #{review_thread.github_id.to_json}) {
+                ... on PullRequestReviewThread {
+                  comments(first: 100, after: #{cursor.to_json}) {
+                    nodes {
+                      id,
+                      createdAt,
+                      author {
+                        login
+                      },
+                      body
+                    },
+                    pageInfo { endCursor, hasNextPage }
+                  }
+                }
+              }
+            }
+          "
+          response = execute.call(query)
+          data = response[:node][:comments]
+
+          {
+            items: data[:nodes],
+            cursor: data[:pageInfo][:endCursor],
+            has_next_page: data[:pageInfo][:hasNextPage]
+          }
+        end
+
+      Enumerators::MapWithPreviousEnumerator.new(comments) do |previous, comment|
+        event_info =
+          PullRequestEventInfo.new(
+            actor:
+              Actor.new(
+                github_login: comment[:author][:login]
+              ),
+            github_id: comment[:id],
+            created_at: Time.parse(comment[:createdAt]),
+          )
+
+        event =
+          PullRequestEvent.create(
+            :review_comment,
+            body: comment[:body],
+            reply_to_github_id: previous[:id],
+            thread: CommentThread.new(github_id: review_thread.github_id)
+          )
+
+        [event_info, event]
+      end
+    end
+
+    def review_threads(pr)
+      events =
+        graphql_client.paginated_query do |execute, cursor|
+          query = "
+            query {
+              repository(owner: #{pr.owner.to_json}, name: #{pr.name.to_json}) {
+                pullRequest(number: #{pr.issue_number.to_json}) {

[... diff too long, it was truncated ...]

GitHub sha: a61bf6dd