Repository.js

'use strict';
/**
 * @file
 * @copyright  2013 Michael Aufreiter (Development Seed) and 2016 Yahoo Inc.
 * @license    Licensed under {@link https://spdx.org/licenses/BSD-3-Clause-Clear.html BSD-3-Clause-Clear}.
 *             Github.js is freely distributable.
 */

import Requestable from './Requestable';
import Utf8 from 'utf8';
import {Base64} from 'js-base64';
import debug from 'debug';
const log = debug('github:repository');

/**
 * Respository encapsulates the functionality to create, query, and modify files.
 */
class Repository extends Requestable {
   /**
    * Create a Repository.
    * @param {string} fullname - the full name of the repository
    * @param {Requestable.auth} [auth] - information required to authenticate to Github
    * @param {string} [apiBase=https://api.github.com] - the base Github API URL
    */
   constructor(fullname, auth, apiBase) {
      super(auth, apiBase);
      this.__fullname = fullname;
      this.__currentTree = {
         branch: null,
         sha: null
      };
   }

   /**
    * Get a reference
    * @see https://developer.github.com/v3/git/refs/#get-a-reference
    * @param {string} ref - the reference to get
    * @param {Requestable.callback} [cb] - will receive the reference's refSpec or a list of refSpecs that match `ref`
    * @return {Promise} - the promise for the http request
    */
   getRef(ref, cb) {
      return this._request('GET', `/repos/${this.__fullname}/git/refs/${ref}`, null, cb);
   }

   /**
    * Create a reference
    * @see https://developer.github.com/v3/git/refs/#create-a-reference
    * @param {Object} options - the object describing the ref
    * @param {Requestable.callback} [cb] - will receive the ref
    * @return {Promise} - the promise for the http request
    */
   createRef(options, cb) {
      return this._request('POST', `/repos/${this.__fullname}/git/refs`, options, cb);
   }

   /**
    * Delete a reference
    * @see https://developer.github.com/v3/git/refs/#delete-a-reference
    * @param {string} ref - the name of the ref to delte
    * @param {Requestable.callback} [cb] - will receive true if the request is successful
    * @return {Promise} - the promise for the http request
    */
   deleteRef(ref, cb) {
      return this._request('DELETE', `/repos/${this.__fullname}/git/refs/${ref}`, null, cb);
   }

   /**
    * Delete a repository
    * @see https://developer.github.com/v3/repos/#delete-a-repository
    * @param {Requestable.callback} [cb] - will receive true if the request is successful
    * @return {Promise} - the promise for the http request
    */
   deleteRepo(cb) {
      return this._request('DELETE', `/repos/${this.__fullname}`, null, cb);
   }

   /**
    * List the tags on a repository
    * @see https://developer.github.com/v3/repos/#list-tags
    * @param {Requestable.callback} [cb] - will receive the tag data
    * @return {Promise} - the promise for the http request
    */
   listTags(cb) {
      return this._request('GET', `/repos/${this.__fullname}/tags`, null, cb);
   }

   /**
    * List the open pull requests on the repository
    * @see https://developer.github.com/v3/pulls/#list-pull-requests
    * @param {Object} options - options to filter the search
    * @param {Requestable.callback} [cb] - will receive the list of PRs
    * @return {Promise} - the promise for the http request
    */
   listPullRequests(options, cb) {
      options = options || {};
      return this._request('GET', `/repos/${this.__fullname}/pulls`, options, cb);
   }

   /**
    * Get information about a specific pull request
    * @see https://developer.github.com/v3/pulls/#get-a-single-pull-request
    * @param {number} number - the PR you wish to fetch
    * @param {Requestable.callback} [cb] - will receive the PR from the API
    * @return {Promise} - the promise for the http request
    */
   getPullRequest(number, cb) {
      return this._request('GET', `/repos/${this.__fullname}/pulls/${number}`, null, cb);
   }

   /**
    * Compare two branches/commits/repositories
    * @see https://developer.github.com/v3/repos/commits/#compare-two-commits
    * @param {string} base - the base commit
    * @param {string} head - the head commit
    * @param {Requestable.callback} cb - will receive the comparison
    * @return {Promise} - the promise for the http request
    */
   compareBranches(base, head, cb) {
      return this._request('GET', `/repos/${this.__fullname}/compare/${base}...${head}`, null, cb);
   }

   /**
    * List all the branches for the repository
    * @see https://developer.github.com/v3/repos/#list-branches
    * @param {Requestable.callback} cb - will receive the list of branches
    * @return {Promise} - the promise for the http request
    */
   listBranches(cb) {
      return this._request('GET', `/repos/${this.__fullname}/branches`, null, cb);
   }

   /**
    * Get a raw blob from the repository
    * @see https://developer.github.com/v3/git/blobs/#get-a-blob
    * @param {string} sha - the sha of the blob to fetch
    * @param {Requestable.callback} cb - will receive the blob from the API
    * @return {Promise} - the promise for the http request
    */
   getBlob(sha, cb) {
      return this._request('GET', `/repos/${this.__fullname}/git/blobs/${sha}`, null, cb, 'raw');
   }

   /**
    * Get a commit from the repository
    * @see https://developer.github.com/v3/repos/commits/#get-a-single-commit
    * @param {string} sha - the sha for the commit to fetch
    * @param {Requestable.callback} cb - will receive the commit data
    * @return {Promise} - the promise for the http request
    */
   getCommit(sha, cb) {
      return this._request('GET', `/repos/${this.__fullname}/git/commits/${sha}`, null, cb);
   }

   /**
    * List the commits on a repository, optionally filtering by path, author or time range
    * @see https://developer.github.com/v3/repos/commits/#list-commits-on-a-repository
    * @param {Object} [options]
    * @param {string} [options.sha] - the SHA or branch to start from
    * @param {string} [options.path] - the path to search on
    * @param {string} [options.author] - the commit author
    * @param {(Date|string)} [options.since] - only commits after this date will be returned
    * @param {(Date|string)} [options.until] - only commits before this date will be returned
    * @param {Requestable.callback} cb - will receive the list of commits found matching the criteria
    * @return {Promise} - the promise for the http request
    */
   listCommits(options, cb) {
      options = options || {};

      options.since = this._dateToISO(options.since);
      options.until = this._dateToISO(options.until);

      return this._request('GET', `/repos/${this.__fullname}/commits`, options, cb);
   }

   /**
    * Get tha sha for a particular object in the repository. This is a convenience function
    * @see https://developer.github.com/v3/repos/contents/#get-contents
    * @param {string} [branch] - the branch to look in, or the repository's default branch if omitted
    * @param {string} path - the path of the file or directory
    * @param {Requestable.callback} cb - will receive a description of the requested object, including a `SHA` property
    * @return {Promise} - the promise for the http request
    */
   getSha(branch, path, cb) {
      branch = branch ? `?ref=${branch}` : '';
      return this._request('GET', `/repos/${this.__fullname}/contents/${path}${branch}`, null, cb);
   }

   /**
    * List the commit statuses for a particular sha, branch, or tag
    * @see https://developer.github.com/v3/repos/statuses/#list-statuses-for-a-specific-ref
    * @param {string} sha - the sha, branch, or tag to get statuses for
    * @param {Requestable.callback} cb - will receive the list of statuses
    * @return {Promise} - the promise for the http request
    */
   listStatuses(sha, cb) {
      return this._request('GET', `/repos/${this.__fullname}/commits/${sha}/statuses`, null, cb);
   }

   /**
    * Get a description of a git tree
    * @see https://developer.github.com/v3/git/trees/#get-a-tree
    * @param {string} treeSHA - the SHA of the tree to fetch
    * @param {Requestable.callback} cb - will receive the callback data
    * @return {Promise} - the promise for the http request
    */
   getTree(treeSHA, cb) {
      return this._request('GET', `/repos/${this.__fullname}/git/trees/${treeSHA}`, null, cb);
   }

   /**
    * Create a blob
    * @see https://developer.github.com/v3/git/blobs/#create-a-blob
    * @param {(string|Buffer|Blob)} content - the content to add to the repository
    * @param {Requestable.callback} cb - will receive the details of the created blob
    * @return {Promise} - the promise for the http request
    */
   createBlob(content, cb) {
      let postBody = this._getContentObject(content);

      log('sending content', postBody);
      return this._request('POST', `/repos/${this.__fullname}/git/blobs`, postBody, cb);
   }

   _getContentObject(content) {
      if (typeof content === 'string') {
         log('contet is a string');
         return {
            content: Utf8.encode(content),
            encoding: 'utf-8'
         };
      } else if (typeof Buffer !== 'undefined' && content instanceof Buffer) {
         log('We appear to be in Node');
         return {
            content: content.toString('base64'),
            encoding: 'base64'
         };
      } else if (typeof Blob !== 'undefined' && content instanceof Blob) {
         log('We appear to be in the browser');
         return {
            content: Base64.encode(content),
            encoding: 'base64'
         };
      } else {
         log(`Not sure what this content is: ${typeof content}, ${JSON.stringify(content)}`);
         throw new Error('Unknown content passed to postBlob. Must be string or Buffer (node) or Blob (web)');
      }
   }

   /**
    * Update a tree in Git
    * @see https://developer.github.com/v3/git/trees/#create-a-tree
    * @param {string} baseTreeSHA - the SHA of the tree to update
    * @param {string} path - the path for the new file
    * @param {string} blobSHA - the SHA for the blob to put at `path`
    * @param {Requestable.callback} cb - will receive the new tree that is created
    * @return {Promise} - the promise for the http request
    * @deprecated use {@link Repository#postTree} instead
    */
   updateTree(baseTreeSHA, path, blobSHA, cb) {
      let newTree = {
         'base_tree': baseTreeSHA,
         'tree': [{
            path: path,
            sha: blobSHA,
            mode: '100644',
            type: 'blob'
         }]
      };

      return this._request('POST', `/repos/${this.__fullname}/git/trees`, newTree, cb);
   }

   /**
    * Create a new tree in git
    * @see https://developer.github.com/v3/git/trees/#create-a-tree
    * @param {Object} tree - the tree to create
    * @param {string} baseSHA - the root sha of the tree
    * @param {Requestable.callback} cb - will receive the new tree that is created
    * @return {Promise} - the promise for the http request
    */
   createTree(tree, baseSHA, cb) {
      return this._request('POST', `/repos/${this.__fullname}/git/trees`, {tree, base_tree: baseSHA}, cb); // jscs:ignore
   }

   /**
    * Add a commit to the repository
    * @see https://developer.github.com/v3/git/commits/#create-a-commit
    * @param {string} parent - the SHA of the parent commit
    * @param {Object} tree - the tree that describes this commit
    * @param {string} message - the commit message
    * @param {Function} cb - will receive the commit that is created
    * @return {Promise} - the promise for the http request
    */
   commit(parent, tree, message, cb) {
      let data = {
         message,
         tree,
         parents: [parent]
      };

      return this._request('POST', `/repos/${this.__fullname}/git/commits`, data, cb)
         .then((response) => {
            this.__currentTree.sha = response.sha; // Update latest commit
            return response;
         });
   }

   /**
    * Update a ref
    * @see https://developer.github.com/v3/git/refs/#update-a-reference
    * @param {string} ref - the ref to update
    * @param {string} commitSHA - the SHA to point the reference to
    * @param {Function} cb - will receive the updated ref back
    * @return {Promise} - the promise for the http request
    */
   updateHead(ref, commitSHA, cb) {
      return this._request('PATCH', `/repos/${this.__fullname}/git/refs/${ref}`, {sha: commitSHA}, cb);
   }

   /**
    * Get information about the repository
    * @see https://developer.github.com/v3/repos/#get
    * @param {Function} cb - will receive the information about the repository
    * @return {Promise} - the promise for the http request
    */
   getDetails(cb) {
      return this._request('GET', `/repos/${this.__fullname}`, null, cb);
   }

   /**
    * List the contributors to the repository
    * @see https://developer.github.com/v3/repos/#list-contributors
    * @param {Function} cb - will receive the list of contributors
    * @return {Promise} - the promise for the http request
    */
   getContributors(cb) {
      return this._request('GET', `/repos/${this.__fullname}/stats/contributors`, null, cb);
   }

   /**
    * List the users who are collaborators on the repository. The currently authenticated user must have
    * push access to use this method
    * @see https://developer.github.com/v3/repos/collaborators/#list-collaborators
    * @param {Function} cb - will receive the list of collaborators
    * @return {Promise} - the promise for the http request
    */
   getCollaborators(cb) {
      return this._request('GET', `/repos/${this.__fullname}/collaborators`, null, cb);
   }

   /**
    * Check if a user is a collaborator on the repository
    * @see https://developer.github.com/v3/repos/collaborators/#check-if-a-user-is-a-collaborator
    * @param {string} username - the user to check
    * @param {Function} cb - will receive true if the user is a collaborator and false if they are not
    * @return {Promise} - the promise for the http request {Boolean} [description]
    */
   isCollaborator(username, cb) {
      return this._request('GET', `/repos/${this.__fullname}/collaborators/${username}`, null, cb);
   }

   /**
    * Get the contents of a repository
    * @see https://developer.github.com/v3/repos/contents/#get-contents
    * @param {string} ref - the ref to check
    * @param {string} path - the path containing the content to fetch
    * @param {boolean} raw - `true` if the results should be returned raw instead of GitHub's normalized format
    * @param {Function} cb - will receive the fetched data
    * @return {Promise} - the promise for the http request
    */
   getContents(ref, path, raw, cb) {
      path = path ? `${encodeURI(path)}` : '';
      return this._request('GET', `/repos/${this.__fullname}/contents/${path}`, {ref}, cb, raw);
   }

   /**
    * Fork a repository
    * @see https://developer.github.com/v3/repos/forks/#create-a-fork
    * @param {Function} cb - will receive the information about the newly created fork
    * @return {Promise} - the promise for the http request
    */
   fork(cb) {
      return this._request('POST', `/repos/${this.__fullname}/forks`, null, cb);
   }

   /**
    * List a repository's forks
    * @see https://developer.github.com/v3/repos/forks/#list-forks
    * @param {Function} cb - will receive the list of repositories forked from this one
    * @return {Promise} - the promise for the http request
    */
   listForks(cb) {
      return this._request('GET', `/repos/${this.__fullname}/forks`, null, cb);
   }

   /**
    * Create a new branch from an existing branch.
    * @param {string} [oldBranch=master] - the name of the existing branch
    * @param {string} newBranch - the name of the new branch
    * @param {Function} cb - will receive the commit data for the head of the new branch
    * @return {Promise} - the promise for the http request
    */
   createBranch(oldBranch, newBranch, cb) {
      if (typeof newBranch === 'function') {
         cb = newBranch;
         newBranch = oldBranch;
         oldBranch = 'master';
      }

      return this.getRef(`heads/${oldBranch}`)
         .then((response) => {
            let sha = response.data.object.sha;
            return this.createRef({sha, ref: `refs/heads/${newBranch}`}, cb);
         });
   }

   /**
    * Create a new pull request
    * @see https://developer.github.com/v3/pulls/#create-a-pull-request
    * @param {Object} options - the pull request description
    * @param {Function} cb - will receive the new pull request
    * @return {Promise} - the promise for the http request
    */
   createPullRequest(options, cb) {
      return this._request('POST', `/repos/${this.__fullname}/pulls`, options, cb);
   }

   /**
    * List the hooks for the repository
    * @see https://developer.github.com/v3/repos/hooks/#list-hooks
    * @param {Function} cb - will receive the list of hooks
    * @return {Promise} - the promise for the http request
    */
   listHooks(cb) {
      return this._request('GET', `/repos/${this.__fullname}/hooks`, null, cb);
   }

   /**
    * Get a hook for the repository
    * @see https://developer.github.com/v3/repos/hooks/#get-single-hook
    * @param {number} id - the id of the webook
    * @param {Function} cb - will receive the details of the webook
    * @return {Promise} - the promise for the http request
    */
   getHook(id, cb) {
      return this._request('GET', `/repos/${this.__fullname}/hooks/${id}`, null, cb);
   }

   /**
    * Add a new hook to the repository
    * @see https://developer.github.com/v3/repos/hooks/#create-a-hook
    * @param {Object} options - the configuration describing the new hook
    * @param {Function} cb - will receive the new webhook
    * @return {Promise} - the promise for the http request
    */
   createHook(options, cb) {
      return this._request('POST', `/repos/${this.__fullname}/hooks`, options, cb);
   }

   /**
    * Edit an existing webhook
    * @see https://developer.github.com/v3/repos/hooks/#edit-a-hook
    * @param {number} id - the id of the webhook
    * @param {Object} options - the new description of the webhook
    * @param {Function} cb - will receive the updated webhook
    * @return {Promise} - the promise for the http request
    */
   updateHook(id, options, cb) {
      return this._request('PATCH', `/repos/${this.__fullname}/hooks/${id}`, options, cb);
   }

   /**
    * Delete a webhook
    * @see https://developer.github.com/v3/repos/hooks/#delete-a-hook
    * @param {number} id - the id of the webhook to be deleted
    * @param {Function} cb - will receive true if the call is successful
    * @return {Promise} - the promise for the http request
    */
   deleteHook(id, cb) {
      return this._request('DELETE', `${this.__repoPath}/hooks/${id}`, null, cb);
   }

   /**
    * Delete a file from a branch
    * @see https://developer.github.com/v3/repos/contents/#delete-a-file
    * @param {string} branch - the branch to delete from, or the default branch if not specified
    * @param {string} path - the path of the file to remove
    * @param {Function} cb - will receive the commit in which the delete occurred
    * @return {Promise} - the promise for the http request
    */
   deleteFile(branch, path, cb) {
      this.getSha(branch, path)
         .then((response) => {
            const deleteCommit = {
               message: `Delete the file at '${path}'`,
               sha: response.data.sha,
               branch
            };
            return this._request('DELETE', `/repos/${this.__fullname}/contents/${path}`, deleteCommit, cb);
         });
   }

   // Move a file to a new location
   // -------
   move(branch, path, newPath, cb) {
      return this._updateTree(branch, function(err, latestCommit) {
         this.getTree(latestCommit + '?recursive=true', function(err, tree) {
            // Update Tree
            tree.forEach(function(ref) {
               if (ref.path === path) {
                  ref.path = newPath;
               }

               if (ref.type === 'tree') {
                  delete ref.sha;
               }
            });

            this.postTree(tree, function(err, rootTree) {
               this.commit(latestCommit, rootTree, 'Deleted ' + path, function(err, commit) {
                  this.updateHead(branch, commit, cb);
               });
            });
         });
      });
   }

   _updateTree(branch, cb) {
      if (branch === this.__currentTree.branch && this.__currentTree.sha) {
         return cb(null, this.__currentTree.sha);
      }

      this.getRef(`heads/${branch}`, function(err, sha) {
         this.__currentTree.branch = branch;
         this.__currentTree.sha = sha;
         cb(err, sha);
      });
   }

   /**
    * Write a file to the repository
    * @see https://developer.github.com/v3/repos/contents/#update-a-file
    * @param {string} branch - the name of the branch
    * @param {string} path - the path for the file
    * @param {string} content - the contents of the file
    * @param {string} message - the commit message
    * @param {Object} [options]
    * @param {Object} [options.author] - the author of the commit
    * @param {Object} [options.commiter] - the committer
    * @param {boolean} [options.encode] - true if the content should be base64 encoded
    * @param {Function} cb - will receive the new commit
    * @return {Promise} - the promise for the http request
    */
   writeFile(branch, path, content, message, options, cb) {
      if (typeof options === 'function') {
         cb = options;
         options = {};
      }
      let filePath = path ? encodeURI(path) : '';
      let shouldEncode = options.encode !== false;
      let commit = {
         branch,
         message,
         author: options.author,
         committer: options.committer,
         content: shouldEncode ? Base64.encode(content) : content
      };

      return this.getSha(branch, filePath)
         .then((response) => {
            commit.sha = response.data.sha;
            return this._request('PUT', `/repos/${this.__fullname}/contents/${filePath}`, commit, cb);
         }, () => {
            return this._request('PUT', `/repos/${this.__fullname}/contents/${filePath}`, commit, cb);
         });
   }

   /**
    * Check if a repository is starred by you
    * @see https://developer.github.com/v3/activity/starring/#check-if-you-are-starring-a-repository
    * @param {Requestable.callback} cb - will receive true if the repository is starred and false if the repository
    *                                  is not starred
    * @return {Promise} - the promise for the http request {Boolean} [description]
    */
   isStarred(cb) {
      return this._request204or404(`/user/starred/${this.__fullname}`, null, cb);
   }

   /**
    * Star a repository
    * @see https://developer.github.com/v3/activity/starring/#star-a-repository
    * @param {Requestable.callback} cb - will receive true if the repository is starred
    * @return {Promise} - the promise for the http request
    */
   star(cb) {
      return this._request('PUT', `/user/starred/${this.__fullname}`, null, cb);
   }

   /**
    * Unstar a repository
    * @see https://developer.github.com/v3/activity/starring/#unstar-a-repository
    * @param {Requestable.callback} cb - will receive true if the repository is unstarred
    * @return {Promise} - the promise for the http request
    */
   unstar(cb) {
      return this._request('DELETE', `/user/starred/${this.__fullname}`, null, cb);
   }

   /**
    * Create a new release
    * @see https://developer.github.com/v3/repos/releases/#create-a-release
    * @param {Object} options - the description of the release
    * @param {Requestable.callback} cb - will receive the newly created release
    * @return {Promise} - the promise for the http request
    */
   createRelease(options, cb) {
      return this._request('POST', `/repos/${this.__fullname}/releases`, options, cb);
   }

   /**
    * Edit a release
    * @see https://developer.github.com/v3/repos/releases/#edit-a-release
    * @param {string} id - the id of the release
    * @param {Object} options - the description of the release
    * @param {Requestable.callback} cb - will receive the modified release
    * @return {Promise} - the promise for the http request
    */
   updateRelease(id, options, cb) {
      return this._request('PATCH', `/repos/${this.__fullname}/releases/${id}`, options, cb);
   }

   /**
    * Get information about a release
    * @see https://developer.github.com/v3/repos/releases/#get-a-single-release
    * @param {strign} id - the id of the release
    * @param {Requestable.callback} cb - will receive the release information
    * @return {Promise} - the promise for the http request
    */
   getRelease(id, cb) {
      return this._request('GET', `/repos/${this.__fullname}/releases/${id}`, null, cb);
   }

   /**
    * Delete a release
    * @see https://developer.github.com/v3/repos/releases/#delete-a-release
    * @param {string} id - the release to be deleted
    * @param {Requestable.callback} cb - will receive true if the operation is successful
    * @return {Promise} - the promise for the http request
    */
   deleteRelease(id, cb) {
      return this._request('DELETE', `/repos/${this.__fullname}/releases/${id}`, null, cb);
   }
}

module.exports = Repository;