CSC/ECE 517 Spring 2019 - E1925 Refactor E1858 Github metrics integration

From Expertiza_Wiki
Jump to navigation Jump to search

Introduction

Problem Statement

Expertiza provides Teammate Reviews under View Scores functionality for each assignment. Purpose of this project is to augment existing assignment submissions with data that can give a more realistic view of the work contribution of every team member using external tools like GitHub. This external data may include: number of commits, number of lines of code modified, number of lines added, number of lines deleted from each group’s submitted repository link from GitHub.

1. Teammate Reviews functionality in the View Scores page gauges teammate views on how much other team members contributed to the project. We need to augment this data with data from external tools like GitHub in order to validate that feedback. New metrics will be appended under each student data under the same functionality.
2. Github Metrics under View Submissions page should include a bar chart that shows number of commits by the team throughout the assignment timeline. This will help instructors to get a team overview, and aid grading process.

While this data will not have marks associated directly, it will prove useful to the instructor in differentiating the performance of team members and hence awarding marks as per contribution. Overall data for the team, like the number of committers and number of commits may also help instructors to predict which projects are likely to be merged.

Current Scenario

Previously, view submission does not show work contribution per teammate.


Teammate review shows peer review amongst teammates. Currently, however, there is no way to validate and verify these reviews.


Checking commits performed by each team member on GitHub is a solution, but that is inefficient from instructor's/reviewer's perspective as there are many assignments, submissions, and tight deadlines.

Use Case Diagram

1. Use Case diagram of two approaches to append 'GitHub contribution metric' in teammate review. 2. Use Case diagram explaining approach to add new column 'GitHub contribution metric' in 'View submission

Use Case Diagram Details

Actors:

  • Instructor: This actor is responsible for viewing GitHub metrics of teams and team members of an assignment.

Pre-Conditions:

  • The Team should have submitted the assignment with a PR link or GitHub repository.

Primary Sequence:

  • The instructor should login.
  • The instructor should browse teams for an assignment.

Post Conditions:

  • Instructor will be able to see the team contribution done by each team member in 'View Submissions' page using graph diagrams, as shown in the figure.
  • Instructor will be able to see the work done by each student in 'Teammate Review Tab' with new metrics table appended at the end, as shown in the figure.

Design Considerations

  • The first thing was to determine what metrics we are looking for exactly. These are what the solution supports:
    1. Number of commits per user and total per team.
    2. Lines of Code added
    3. Lines of code deleted.
  • The next thing was to narrow down what hosting service for version control we would use. For now, we only support GitHub integration due to its popularity, ease-of-use and API documentation. Future projects could add in support for Gitlab and others, though it is far easier to just require that all students use GitHub.
    1. The main impact of this change will be that all submission repositories need to be made public as we need access to pull in the data.
    2. We also considered whether to ask students for GitHub repository link separately (changes to views) or to parse all the uploaded links and determine the correct one (extra logic, students uploading multiple links or not giving links at all). We decided to go with parsing the links as giving the link to PR is anyway mandatory.
  • An important question was whether we needed to store metric information in our own db at all.
    1. An older investigation came up with this schema, but this would likely cause issues with stale information and would have been difficult to maintain.
    2. Having a db was redundant as every time a user wants to see the metrics, we would need to sync the db with GitHub and then show the results. So we end up hitting GitHub API anyway.
    3. An alternative to the above approach was to take snapshots of metrics and store them in the db right on the submission deadline of projects. This would allow for fairer grading by making sure we pull in data at the correct moment. Unfortunately, doing this for so many projects would put a lot of load on the server. Also, for open source projects, this would mean that we don’t have the latest data to work with (people will keep committing past the deadline). Thus, this approach might have been good for grading purposes but wouldn't have helped with determining the current status of a project.
    4. We have decided against using our own tables for this data and will be getting the GitHub data on-demand directly using the GitHub API.
  • We also considered if we needed to account for different branches. We only consider the master branch.
  • With respect to showing GitHub metrics in the View scores page, it would have been very difficult to map Expertiza users and their names to public GitHub profiles as students may use a different name. So instead of appending GitHub data to Teammate reviews table, we will be showing a new table below it to display the metrics. This will allow the instructor full view of how teammate rated each other and how that maps to factual information from GitHub.
  • The instructors will need to spell out exact guidelines for committing to the project's repositories (like everyone should commit their own code, keep the master as PR branch, commit regularly, be mindful of squashing too many commits for one user), so that we can have proper and correct data and, also so that students can’t weasel their way out later claiming they worked but forgot or didn’t know.

Design Principles

  • MVC – The project is implemented in Ruby on Rails that uses MVC architecture. It separates an application’s data model, user interface, and control logic into three distinct components (model, view and controller, respectively). We intend to follow the same when implementing our end-point for pulling GitHub data.
  • Dry Principle – We are trying to reuse the existing functionalities in Expertiza, thus avoiding code duplication. Whenever possible, code modification based on the existing classes, controllers, or tables will be done instead of creating the new one.

Solution Design

  • The Github metrics that need to be integrated with Expertiza were finalized as below. These metrics are captured on a per-user basis:
    1. Total number of commits.
    2. Lines of Code added
    3. Lines of code deleted.
    4. Pull Request Status ( includes code climate and Travis CI Build status)
    5. User Github metrics:
      1. Committer ID
      2. Committer Name
      3. Committer email ID
  • A new link "Github Metrics" is provided under “View Submissions” for an assignment in the instructor view.This link opens a new tab and shows a stacked bar chart for number of commits per user vs submission timeline from assignment creation date to the deadline.
  • In "View Scores" for an assignment in the instructor view, under Teammate Reviews tab, a new table for Github Metrics is added, which shows following Github metrics per user:
Student Name/ID, Email ID, lines of code added, lines of code deleted, number of commits
  • For GitHub integration, we have used GitHub GraphQL API v4. We have used github-omniauth gem for authentication/authorization purposes.
  • We parse the link to PR to get data associated with it. We have also handled projects which do not have PR link, but just a link to the repository. We excluded expertiza and servo projects as right now a PR link is expected. Future enhancements can look into getting separate GitHub submission links.
  • We also show the status of check runs in the View Github metrics view to help instructors view the status of various tools on the repos/PRs without having to go to the actual GitHub page.

Implemented Solution

Files Modified

  • app/controllers/auth_controller.rb
  • app/controllers/grades_controller.rb
  • app/helpers/grades_helper.rb
  • app/views/assignments/list_submissions.html.erb
  • app/views/grades/_tabbing.html.erb
  • app/views/grades/_teammate_reviews_tab.html.erb
  • app/views/grades/view.html.erb
  • app/views/grades/view_team.html.erb
  • config/application.rb
  • config/initializers/load_config.rb
  • config/initializers/omniauth.rb
  • config/routes.rb

Files Added

  • app/views/grades/view_github_metrics.html.erb
  • config/github_auth.yml


First Change

  • A new table "Github Metrics" is added under Manage-> Assignments -> View Scores -> Teammate Reviews. Below is the screenshot of the implementation.
The GitHub metrics table shows results for each team member

Second Change

  • The second change is in the View Submissions page, where we have added a link "Github Metrics" to a new page.
At present, view submission shows group assignments are submitted as a single submission and 'view github metric' link shows work contribution per teammate.
  • The new page appears after clicking on the link "Github metrics", that shows bar chart for # of commits per day. We have also added other relevant information about Pull Request, such as total commits, lines of code added, lines of code modified, PR merge status, check status.
The bar chart
The Github summary

Code Change in Grades_Controller

  • Added below new functions to implement Github Integration in View Submission page


  def get_statuses_for_pull_request(ref)
    url = "https://api.github.com/repos/expertiza/expertiza/commits/" + ref + "/status"
    ActiveSupport::JSON.decode(Net::HTTP.get(URI(url)))
  end

  def retrieve_pull_request_data(pull_links)
    pull_links.each do |hyperlink|
      submission_hyperlink_tokens = hyperlink.split('/')
      hyperlink_data = {}
      hyperlink_data["pull_request_number"] = submission_hyperlink_tokens.pop
      submission_hyperlink_tokens.pop
      hyperlink_data["repository_name"] = submission_hyperlink_tokens.pop
      hyperlink_data["owner_name"] = submission_hyperlink_tokens.pop
      github_data = get_pull_request_details(hyperlink_data)
      parse_github_data_pull(github_data)
    end
  end

  def retrieve_repository_data(repo_links)
    repo_links.each do |hyperlink|
      submission_hyperlink_tokens = hyperlink.split('/')
      hyperlink_data = {}
      hyperlink_data["repository_name"] = submission_hyperlink_tokens[4]
      next if hyperlink_data["repository_name"] == "servo" || hyperlink_data["repository_name"] == "expertiza"
      hyperlink_data["owner_name"] = submission_hyperlink_tokens[3]
      github_data = get_github_data_repo(hyperlink_data)
      parse_github_data_repo(github_data)
    end
  end

  def retrieve_github_data
    team_links = @team.hyperlinks
    pull_links = team_links.select do |link|
      link.match(/pull/) && link.match(/github.com/)
    end
    if !pull_links.empty?
      retrieve_pull_request_data(pull_links)
    else
      repo_links = team_links.select do |link|
        link.match(/github.com/)
      end
      retrieve_repository_data(repo_links)
    end
  end

  def retrieve_check_run_statuses
    @head_refs.each do |pull_number, ref|
      @check_statuses[pull_number] = get_statuses_for_pull_request(ref)
    end
  end

  def view_github_metrics
    if session["github_access_token"].nil?
      session["participant_id"] = params[:id]
      session["github_view_type"] = "view_submissions"
      redirect_to authorize_github_grades_path
      return
    end

    @head_refs = {}
    @parsed_data = {}
    @authors = {}
    @dates = {}
    @total_additions = 0
    @total_deletions = 0
    @total_commits = 0
    @total_files_changed = 0
    @merge_status = {}
    @check_statuses = {}

    @token = session["github_access_token"]

    @participant = AssignmentParticipant.find(params[:id])
    @assignment = @participant.assignment
    @team = @participant.team
    @team_id = @team.id

    retrieve_github_data
    retrieve_check_run_statuses

    @authors = @authors.keys
    @dates = @dates.keys.sort
  end

  def authorize_github
    redirect_to "https://github.com/login/oauth/authorize?client_id=#{GITHUB_CONFIG['client_key']}"
  end

  def get_github_data_repo(hyperlink_data)
    data = {
      query: "query {
        repository(owner: \"" + hyperlink_data["owner_name"] + "\", name: \"" + hyperlink_data["repository_name"] + "\") {
          ref(qualifiedName: \"master\") {
            target {
              ... on Commit {
                id
                  history(first: 100) {
                    edges {
                      node {
                        id author {
                          name email date
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }"
    }
    make_github_api_request(data)
  end

  def get_pull_request_details(hyperlink_data)
    response_data = make_github_api_request(get_query(true, hyperlink_data))

    @has_next_page = response_data["data"]["repository"]["pullRequest"]["commits"]["pageInfo"]["hasNextPage"]
    @end_cursor = response_data["data"]["repository"]["pullRequest"]["commits"]["pageInfo"]["endCursor"]

    while @has_next_page
      new_response_data = make_github_api_request(get_query(false, hyperlink_data))
      response_data["data"]["repository"]["pullRequest"]["commits"]["edges"].push(*new_response_data["data"]["repository"]["pullRequest"]["commits"]["edges"])
      @has_next_page = new_response_data["data"]["repository"]["pullRequest"]["commits"]["pageInfo"]["hasNextPage"]
      @end_cursor = new_response_data["data"]["repository"]["pullRequest"]["commits"]["pageInfo"]["endCursor"]
    end

    response_data
  end

  def process_github_authors_and_dates(author_name, commit_date)
    @authors[author_name] ||= 1
    @dates[commit_date] ||= 1
    @parsed_data[author_name] ||= {}
    @parsed_data[author_name][commit_date] = if @parsed_data[author_name][commit_date]
                                               @parsed_data[author_name][commit_date] + 1
                                             else
                                               1
                                             end
  end

  def parse_github_data_pull(github_data)
    team_statistics(github_data)
    pull_request_object = github_data["data"]["repository"]["pullRequest"]
    commit_objects = pull_request_object["commits"]["edges"]
    commit_objects.each do |commit_object|
      commit = commit_object["node"]["commit"]
      author_name = commit["author"]["name"]
      commit_date = commit["committedDate"].to_s
      process_github_authors_and_dates(author_name, commit_date[0, 10])
    end
    organize_commit_dates
  end

  def parse_github_data_repo(github_data)
    commit_history = github_data["data"]["repository"]["ref"]["target"]["history"]
    commit_objects = commit_history["edges"]
    commit_objects.each do |commit_object|
      commit_author = commit_object["node"]["author"]
      author_name = commit_author["name"]
      commit_date = commit_author["date"].to_s
      process_github_authors_and_dates(author_name, commit_date[0, 10])
    end
    organize_commit_dates
  end

  def make_github_api_request(data)
    uri = URI.parse("https://api.github.com/graphql")
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    http.verify_mode = OpenSSL::SSL::VERIFY_PEER
    request = Net::HTTP::Post.new(uri.path, 'Authorization' => 'Bearer' + ' ' + session["github_access_token"])
    request.body = data.to_json
    http.request(request)
    response = http.request(request)
    ActiveSupport::JSON.decode(response.body.to_s)
  end

  def organize_commit_dates
    @dates.each_key do |date|
      @parsed_data.each_value do |commits|
        commits[date] ||= 0
      end
    end
    @parsed_data.each {|author, commits| @parsed_data[author] = Hash[commits.sort_by {|date, _commit_count| date }] }
  end

  def team_statistics(github_data)
    @total_additions += github_data["data"]["repository"]["pullRequest"]["additions"]
    @total_deletions += github_data["data"]["repository"]["pullRequest"]["deletions"]
    @total_files_changed += github_data["data"]["repository"]["pullRequest"]["changedFiles"]
    @total_commits += github_data["data"]["repository"]["pullRequest"]["commits"]["totalCount"]
    pull_request_number = github_data["data"]["repository"]["pullRequest"]["number"]
    @head_refs[pull_request_number] = github_data["data"]["repository"]["pullRequest"]["headRefOid"]

    @merge_status[pull_request_number] = if github_data["data"]["repository"]["pullRequest"]["merged"]
                                           "MERGED"
                                         else
                                           github_data["data"]["repository"]["pullRequest"]["mergeable"]
                                         end
  end

  def get_query(is_initial_page, hyperlink_data)
    commit_query_line = if is_initial_page
                          "commits(first:100){"
                        else
                          "commits(first:100, after:" + @end_cursor + "){"
                        end
    {
      query: "query {
        repository(owner: \"" + hyperlink_data["owner_name"] + "\", name:\"" + hyperlink_data["repository_name"] + "\") {
          pullRequest(number: " + hyperlink_data["pull_request_number"] + ") {
            number additions deletions changedFiles mergeable merged headRefOid
              " + commit_query_line + "
                totalCount
                  pageInfo{
                    hasNextPage startCursor endCursor
                    }
                      edges{
                        node{
                          id  commit{
                                author{
                                  name
                                }
                               additions deletions changedFiles committedDate
                        }}}}}}}"
    }
  end


Test Plan

Change 1: GitHub metrics in teammate reviews

1) Log in as an instructor (instructor6/password)

2) Navigate to assignments through Manage --> Assignments

3) Select "View scores" icon for the assignment of your choice

4) Select the team for which you wish to view scores

5) Go to "Teammate Reviews" tab

6) View data per team member based on different GitHub metrics (e.g. lines of code added/changed/removed etc.)


Change 2: Bar chart for # of commits changed by the overall team

1) Log in as an instructor (instructor6/password)

2) Navigate to assignments through Manage --> Assignments

3) Select "View submissions" icon for the assignment of your choice

4) Click on the "Github metrics" link for the team whose metrics you wish to view

5) A new page opens and shows # of commits changed per team member since the start of the assignment, also bottom of the page shows summary from Github submissions.


RSpec Tests

  • Following feature tests were added to the Grades_Controller_Spec.rb
 
  describe '#get_statuses_for_pull_request' do
    before(:each) do
      allow(Net::HTTP).to receive(:get) {"{\"team\":\"rails\",\"players\":\"36\"}"}
    end

    it 'makes a call to the GitHub API to get status of the head commit passed' do
      expect(controller.get_statuses_for_pull_request('qwerty123')).to eq({"team" => "rails", "players" => "36"})
    end
  end

  describe '#retrieve_pull_request_data' do
    before(:each) do
      allow(controller).to receive(:get_pull_request_details).and_return({"pr" => "details"})
      allow(controller).to receive(:parse_github_data_pull)
    end

    it 'gets pull request details for each PR link submitted' do
      expect(controller).to receive(:get_pull_request_details).with(
          {
              "pull_request_number" => "1261",
              "repository_name" => "expertiza",
              "owner_name" => "expertiza"
          })
      expect(controller).to receive(:get_pull_request_details).with(
          {
              "pull_request_number" => "1293",
              "repository_name" => "mamaMiya",
              "owner_name" => "Shantanu"
          })
      controller.retrieve_pull_request_data(["https://github.com/expertiza/expertiza/pull/1261", "https://github.com/Shantanu/mamaMiya/pull/1293"])
    end

    it 'calls parse_github_data_pull on each of the PR details' do
      expect(controller).to receive(:parse_github_data_pull).with({"pr" => "details"}).twice
      controller.retrieve_pull_request_data(["https://github.com/expertiza/expertiza/pull/1261", "https://github.com/Shantanu/mamaMiya/pull/1293"])
    end
  end

  describe '#retrieve_repository_data' do
    before(:each) do
      allow(controller).to receive(:get_github_data_repo).and_return({"pr" => "details"})
      allow(controller).to receive(:parse_github_data_repo)
    end

    it 'gets details for each repo link submitted, excluding those for expertiza and servo' do
      expect(controller).to receive(:get_github_data_repo).with(
          {
              "repository_name" => "website",
              "owner_name" => "Shantanu"
          })
      expect(controller).to receive(:get_github_data_repo).with(
          {
              "repository_name" => "OODD",
              "owner_name" => "Edward"
          })
      controller.retrieve_repository_data(["https://github.com/Shantanu/website", "https://github.com/Edward/OODD", "https://github.com/expertiza/expertiza", "https://github.com/Shantanu/expertiza]"])
    end

    it 'calls parse_github_data_repo on each of the PR details' do
      expect(controller).to receive(:parse_github_data_repo).with({"pr" => "details"}).twice
      controller.retrieve_repository_data(["https://github.com/Shantanu/website", "https://github.com/Edward/OODD"])
    end
  end

  describe '#retrieve_github_data' do
    before(:each) do
      allow(controller).to receive(:retrieve_pull_request_data)
      allow(controller).to receive(:retrieve_repository_data)
    end

    context 'when pull request links have been submitted' do
      before(:each) do
        teams_mock = double
        allow(teams_mock).to receive(:hyperlinks).and_return(["https://github.com/Shantanu/website", "https://github.com/Shantanu/website/pull/1123"])
        controller.instance_variable_set(:@team, teams_mock)
      end

      it 'retrieves PR data only' do
        expect(controller).to receive(:retrieve_pull_request_data).with(["https://github.com/Shantanu/website/pull/1123"])
        controller.retrieve_github_data
      end
    end

    context 'when pull request links have not been submitted' do
      before(:each) do
        teams_mock = double
        allow(teams_mock).to receive(:hyperlinks).and_return(["https://github.com/Shantanu/website", "https://github.com/expertiza/expertiza"])
        controller.instance_variable_set(:@team, teams_mock)
      end

      it 'retrieves repo details ' do
        expect(controller).to receive(:retrieve_repository_data).with(["https://github.com/Shantanu/website", "https://github.com/expertiza/expertiza"])
        controller.retrieve_github_data
      end
    end
  end

  describe '#retrieve_check_run_statuses' do
    before(:each) do
      allow(controller).to receive(:get_statuses_for_pull_request).and_return("check_status")
      controller.instance_variable_set(:@headRefs, {"1234" => "qwerty", "5678" => "asdfg"})
      controller.instance_variable_set(:@check_statuses, {})
    end

    it 'gets and stores the statuses associated with head commits of PRs' do
      expect(controller).to receive(:get_statuses_for_pull_request).with("qwerty")
      expect(controller).to receive(:get_statuses_for_pull_request).with("asdfg")
      controller.retrieve_check_run_statuses
      expect(controller.instance_variable_get(:@check_statuses)).to eq({"1234" => "check_status", "5678" => "check_status"})
    end
  end

  describe '#view_github_metrics' do
    context 'when user hasn\'t logged in to GitHub' do
      before(:each) do
        @params = {id: 900}
        session["github_access_token"] = nil
      end

      it 'stores the current participant id and the view action' do
        get :view_github_metrics, @params
        expect(session["participant_id"]).to eq("900")
        expect(session["github_view_type"]).to eq("view_submissions")
      end

      it 'redirects user to GitHub authorization page' do
        get :view_github_metrics, @params
        expect(response).to redirect_to(authorize_github_grades_path)
      end
    end

    context 'when user has logged in to GitHub' do
      before(:each) do
        session["github_access_token"] = "qwerty"
        allow(controller).to receive(:get_statuses_for_pull_request).and_return("status")
        allow(controller).to receive(:retrieve_github_data)
        allow(controller).to receive(:retrieve_check_run_statuses)
      end

      it 'stores the GitHub access token for later use' do
        get :view_github_metrics, {id: '1'}
        expect(controller.instance_variable_get(:@token)).to eq("qwerty")
      end

      it 'calls retrieve_github_data to retrieve data from GitHub' do
        expect(controller).to receive(:retrieve_github_data)
        get :view_github_metrics, {id: '1'}
      end

      it 'calls retrieve_check_run_statuses to retrieve check runs data' do
        expect(controller).to receive(:retrieve_check_run_statuses)
        get :view_github_metrics, {id: '1'}
      end
    end
  end
end

 


References

Expertiza_wiki

E1815:_Improvements_to_review_grader

Expertiza_PR_1179

Expertiza_PR_1179_Video

GitHub API documentation