{"action":"created","comment":{"url":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/comments/154099935","pull_request_review_id":80204846,"id":154099935,"diff_hunk":"@@ -1,220 +1,519 @@\n 'use strict';\n \n-const pgp = require('pg-promise')();\n-const db = pgp(process.env.DB_CONNECTION);\n+const pgp = require('pg-promise')({});\n+const pg = pgp(process.env.DB_CONNECTION);\n+const sqlite3 = require('sqlite3').verbose(); // TODO: remove `verbose()` in production\n+const fpr = new sqlite3.Database(process.env.SQLITE_LOCATION + '/fpr.db', sqlite3.OPEN_READWRITE);\n+const logger = require('winston');\n \n-module.exports = {\n-  getFileQc: getFileQcBySwid,\n-  getAllFileQcs: getAllFileQcs,\n-  addFileQc: addFileQc,\n-  addManyFileQcs: addManyFileQcs\n-};\n+// configure SQLite connection so that reading from and writing to are non-blocking\n+fpr.run('PRAGMA journal_mode = WAL;');\n \n-// set up custom error if bad params are given\n+/** set up custom error if bad params are given */\n function ValidationError(message) {\n   this.message = message;\n }\n ValidationError.prototype = Error.prototype;\n \n-function getFileQcBySwid(req, res, next) {\n+/**\n+ * Get a single FileQC by File SWID\n+ */\n+const getFileQcBySwid = async (req, res, next) => {\n   let swid;\n+  swid = validateSwid(req.params.identifier, next);\n+\n   try {\n-    swid = validateSwid(req.params.identifier, next);\n+    const results = await Promise.all([getSingleFprResult(swid), getSingleFqcResult(swid)]);\n+    // merge results from FPR and FQC queries (throws if we don't have either an FPR result or an FQC result)\n+    const mergedFileQc = mergeOneFileResult(results[0], results[1].fileqc);\n+    res.status(200).json({ fileqc: mergedFileQc, errors: results[1].errors });\n+    next();\n+  } catch (e) { \n+    handleErrors(e, 'Error getting record', next);\n+  }\n+};\n+\n+/**\n+ * Get all FileQCs restricted by Project or File SWIDs\n+ */\n+const getAllFileQcs = async (req, res, next) => {\n+  let proj, swids, qcStatus;\n+  proj = nullifyIfBlank(req.query.project);\n+  swids = validateSwids(req.query.fileswids);\n+  qcStatus = req.query.qcstatus; \n+\n+  let results;\n+  try {\n+    if (qcStatus === null || typeof qcStatus == 'undefined') {\n+      results = await getByProjOrSwids(proj, swids, next);\n+    } else {\n+      results = await getByProjAndQcStatus(proj, qcStatus, next);\n+    }\n+    res.status(200).json({ fileqcs: results, errors: [] });\n+    next();\n   } catch (e) {\n-    if (e instanceof ValidationError) return next(generateError(400, e.message));\n+    handleErrors(e, 'Error getting records', next);\n   }\n+};\n \n-  const sql = 'SELECT * FROM FileQC WHERE fileswid = $1';\n-  db.any(sql, [swid])\n-    .then(data => { \n-      if (!data || data.length == 0) {\n-        return next(generateError(404, 'No FileQC found for file with SWID ' + swid));\n-      }\n-      \n-      // TODO: something about DTOifying the response?\n-      res.status(200)\n-        .json({ fileqc: data, errors: [] });\n-      next();\n-    })\n-    .catch(err => {\n-      console.log(err);\n-      return next(generateError(500, 'Error retrieving record'));\n-    });\n-}\n+const getByProjOrSwids = async (proj, swids, next) => {\n+  let getFprByProjOrSwids, getFqcByProjOrSwids;\n+  if (proj != null) {\n+    getFprByProjOrSwids = () => getFprResultsByProject(proj);\n+    getFqcByProjOrSwids = () => getFqcResultsByProject(proj);\n+  } else if (swids != null) {\n+    getFprByProjOrSwids = () => getFprResultsBySwids(swids);\n+    getFqcByProjOrSwids = () => getFqcResultsBySwids(swids);\n+  } else {\n+    throw new ValidationError('Must supply project or fileswid(s)');\n+  }\n \n-function getAllFileQcs(req, res, next) {\n-  // access the project param\n-  let proj;\n   try {\n-    proj = validateProject(req.query.project);\n+    const results = await Promise.all([getFprByProjOrSwids(), getFqcByProjOrSwids()]);\n+    // merge the results from the File Provenance report and the FileQC database\n+    return mergeFileResults(results[0], results[1].fileqcs);\n   } catch (e) {\n-    if (e instanceof ValidationError) return next(generateError(400, e.message));\n+    throw e;\n   }\n \n-  const sql = 'SELECT * FROM FileQC WHERE project = $1';\n-  db.any(sql, [proj])\n-    .then(data => {\n-      // TODO: something in here about DTOifying the responses\n-      res.status(200)\n-        .json({ fileqcs: data, errors: [] });\n-      next();\n-    })\n-    .catch(err => {\n-      console.log(err);\n-      return next(generateError(500, 'Error retrieving records'));\n-    });\n-}\n+};\n \n-function addFileQc(req, res, next) {\n-  // TODO: fix this\n-  const fqc = {};\n+const getByProjAndQcStatus = async (proj, qcStatus) => {\n   try {\n-    fqc.project = validateProject(req.query.project);\n-    fqc.filepath = validateFilepath(req.query.filepath);\n-    fqc.fileswid = validateSwid(req.query.fileswid);\n-    fqc.username = validateUsername(req.query.username);\n-    fqc.comment = validateComment(req.query.comment);\n-    fqc.qcpassed = validateQcStatus(req.query.qcstatus);\n+    const qcpassed = validateQcStatus(qcStatus, false);\n+    if (qcpassed === null) {\n+      // get only items from FPR that are not in FileQC\n+      let fileQcSwids = await getAllFileQcSwids();\n+      fileQcSwids = fileQcSwids.map(o => parseInt(o.fileswid));\n+      const pendingFprs = await getFprsNotInFileQc(proj, fileQcSwids);\n+      return mergeFileResults(pendingFprs, []);\n+    } else {\n+      // get only the items which are listed as either PASS or FAIL in FileQC\n+      const fqcs = await getFqcResultsByProjAndQcStatus(proj, qcpassed);\n+      const swids = fqcs.map(record => record.fileswid);\n+      const fprs = await getFprResultsBySwids(swids);\n+      return mergeFileResults(fprs, fqcs);\n+    }\n   } catch (e) {\n-    if (e instanceof ValidationError) return next(generateError(400, e.message));\n+    throw e;\n   }\n+};\n \n-  // update if exists, insert if not\n-  const upsert = 'INSERT INTO FileQc as fqc (filepath, qcpassed, username, comment, fileswid, project) VALUES (${filepath}, ${qcpassed}, ${username}, ${comment}, ${fileswid}, ${project}) ON CONFLICT (fileswid) DO UPDATE SET filepath = ${filepath}, qcpassed = ${qcpassed}, username = ${username}, comment = ${comment} WHERE fqc.fileswid = ${fileswid}';\n+/**\n+ * Add a single FileQC\n+ */\n+const addFileQc = async (req, res, next) => {\n+  try {\n+    const fqc = {\n+      project: validateProject(req.query.project),\n+      filepath: validateFilepath(req.query.filepath),\n+      fileswid: validateSwid(req.query.fileswid),\n+      username: validateUsername(req.query.username),\n+      comment: validateComment(req.query.comment),\n+      qcpassed: validateQcStatus(req.query.qcstatus, true)\n+    };\n \n-  db.none(upsert, fqc)\n-    .then(() => {\n-      res.status(201)\n-        .json({ fileswid: fqc.fileswid, errors: [] });\n-      next();\n-    })\n-    .catch(err => {\n-      console.log(err); // TODO: fix this into proper logging and debugging\n-      if (err.error.contains('duplicate key') && err.error.contains('filepath')) {\n-        next(generateError(400, 'FileQC at path ' + fqc.filepath + ' is already associated with a different fileSWID'));\n-      } else {\n-        next(generateError(500, 'Failed to create FileQC record'));\n-      }\n-    });\n-}\n+    const results = await Promise.all([getSingleFprResult(fqc.fileswid), upsertSingleFqc(fqc)]);\n+    // merge the results\n+    const merged = mergeOneFileResult(results[0], { fileswid: fqc.fileswid, qcpassed: fqc.qcpassed });\n+    res.status(201).json({ fileqc: merged, errors: results[1].errors });\n+    next();\n+  } catch (e) {\n+    handleErrors(e, 'Error adding record', next);\n+  }\n+};\n \n-function addManyFileQcs(req, res, next) {\n+/**\n+ * Batch add FileQCs\n+ */\n+const addManyFileQcs = async (req, res, next) => {\n   if (!req.body.fileqcs) return next(generateError(400, 'Error: no FileQCs found in request body'));\n \n-  const validationResults = validateObjectsFromUser(req.body.fileqcs, req.body.project);\n+  const validationResults = validateObjectsFromUser(req.body.fileqcs);\n   if (validationResults.errors.length) return next(generateError(400, validationResults.errors));\n   const toSave = validationResults.validated;\n+  const swids = toSave.map(record => record.fileswid);\n \n-  const upsert = 'INSERT INTO FileQc as fqc (filepath, qcpassed, username, comment, fileswid, project) VALUES (${filepath}, ${qcpassed}, ${username}, ${comment}, ${fileswid}, ${project}) ON CONFLICT (fileswid) DO UPDATE SET filepath = ${filepath}, qcpassed = ${qcpassed}, username = ${username}, comment = ${comment} WHERE fqc.fileswid = ${fileswid}';\n+  try {\n+    const results = await Promise.all([getFprResultsBySwids(swids), upsertFqcs(toSave)]);\n+    // merge the results\n+    const merged = mergeFileResults(results[0], results[1].fileqcs);\n+    res.status(200).json({ fileqcs: merged, errors: results[1].errors });\n+    next();\n+  } catch (e) {\n+    handleErrors(e, 'Error adding records', next);\n+  }\n+};\n \n-  db.tx('batch', t => {\n-    const queries = [];\n-    for (let i = 0; i < toSave.length; i++) {\n-      queries.push(t.none(upsert, toSave[i]));\n-    }\n-    return t.batch(queries);\n-  })\n-    .then(()=> {\n-      const returnInfo = toSave.map(fqc => { return { fileswid: fqc.fileswid, upstream: [] }; });\n-      res.status(201)\n-        .json({ errors: [], fileqcs: returnInfo });\n-    })\n-    .catch(err => {\n-      console.log(err); // TODO: fix this into proper logging and debugging\n-      return next(generateError(500, err.error));\n+/** success returns the last time that the File Provenance Report was imported into SQLite */\n+const getMostRecentFprImportTime = () => {\n+  return new Promise((resolve, reject) => {\n+    fpr.get('SELECT * FROM fpr_import_time ORDER BY lastimported DESC LIMIT 1', [], (err, data) => {\n+      if (err) reject(err);\n+      resolve(new Date(data.lastimported).getTime()); \n     });\n-}\n+  });\n+};\n \n // validation functions\n-function validateProject(param) {\n+function validateProject (param) {\n   const proj = param || null;\n   if (proj == null || !proj.length) throw new ValidationError('Project must be provided');\n   return proj;\n }\n \n-function validateSwid(param) {\n+function validateSwid (param) {\n   const swid = parseInt(param);\n   if (Number.isNaN(swid)) throw new ValidationError('FileSWID is ' + param + ' but must be an integer');\n   return swid;\n }\n \n-function validateUsername(param) {\n+/** Expects a comma-separated list of File SWIDs and returns the valid numbers within */\n+function validateSwids (param) {\n+  if (nullifyIfBlank(param) == null) return null;\n+  return param.split(',').map(num => parseInt(num)).filter(num => !Number.isNaN(num));\n+}\n+\n+function validateUsername (param) {\n   const user = nullifyIfBlank(param);\n   if (user == null || !user.length) throw new ValidationError('Username must be provided');\n   return user;\n }\n \n-function validateComment(param) {\n+function validateComment (param) {\n   let comment = nullifyIfBlank(param);\n   if (comment !== null) comment = decodeURIComponent(comment.replace(/\\+/g,  ' '));\n   return comment;\n }\n \n-function validateFilepath(param) {\n+function validateFilepath (param) {\n   let fp = nullifyIfBlank(param);\n   if (fp == null || !fp.length) throw new ValidationError('Filepath must be provided');\n   fp = decodeURIComponent(fp.replace(/\\+/g, ' '));\n   return fp;\n }\n \n-function validateQcStatus(param) {\n+function validateQcStatus (param, throwIfNull) {\n   let qcPassed = nullifyIfBlank(param);\n-  if (qcPassed === null) throw new ValidationError('FileQC must be saved with qcstatus \"PASS\" or \"FAIL\"');\n+  if (qcPassed === null && throwIfNull) throw new ValidationError('FileQC must be saved with qcstatus \"PASS\" or \"FAIL\"');\n   qcPassed = convertQcStatusToBoolean(qcPassed);\n   return qcPassed;\n }\n \n-function generateError(statusCode, errorMessage) {\n-  const err = {\n-    statusCode: statusCode,\n-    errors: [errorMessage]\n-  };\n-  return err;\n-}\n-\n-function nullifyIfBlank(value) {\n+function nullifyIfBlank (value) {\n   if (typeof value == 'undefined' || value === null || value.length == 0) value = null;\n   return value;\n }\n \n /** Must deal with null qcStatus check elsewhere */\n-function convertQcStatusToBoolean(value) {\n+function convertQcStatusToBoolean (value) {\n   value = value.toLowerCase();\n   const statusToBool = {\n     'pass': true,\n     'fail': false,\n     'pending': null\n   };\n-  if (statusToBool[value] == null) throw new ValidationError('Unknown QC status ' + value);\n+  if (typeof statusToBool[value] == 'undefined') throw new ValidationError('Unknown QC status ' + value);\n   return statusToBool[value];\n }\n \n+function generateError (statusCode, errorMessage) {\n+  const err = {\n+    status: statusCode,\n+    errors: [errorMessage]\n+  };\n+  return err;\n+}\n+\n+function handleErrors (e, defaultMessage, next) {\n+  if (e instanceof ValidationError) {\n+    logger.info(e.message);\n+    next(generateError(400, e.message));\n+  } else if (e.status) {\n+    logger.info(e.message);\n+    return next(e); // generateError has already been called, usually because it's a user error\n+  } else {\n+    logger.error(e);\n+    next(generateError(500, defaultMessage));\n+  }\n+}\n+\n /** returns an object { validated: {}, errors: [] } */\n-function validateObjectsFromUser(unvalidatedObjects, unvalidatedProject) {\n+function validateObjectsFromUser (unvalidatedObjects) {\n   let validationErrors = [];\n-  let validatedParams = unvalidatedObjects.map(validateFileQcObject);\n-  return { validated: validatedParams, errors: validationErrors };\n-  \n-  function validateFileQcObject(unvalidated) {\n-    // project may be passed in separately from the fileqcs, or as part of the fileqcs array\n-    const proj = unvalidatedProject || unvalidated.project;\n-    let validated = {};\n+  let validatedParams = unvalidatedObjects.map(unvalidated => {\n     try {\n-      validated.filepath = validateFilepath(unvalidated.filepath);\n-      validated.qcpassed = validateQcStatus(unvalidated.qcstatus);\n-      validated.username = validateUsername(unvalidated.username);\n-      validated.comment = validateComment(unvalidated.comment);\n-      validated.fileswid = validateSwid(unvalidated.fileswid);\n-      validated.project = validateProject(proj);\n+      return {\n+        filepath: validateFilepath(unvalidated.filepath),\n+        qcpassed: validateQcStatus(unvalidated.qcstatus, true),\n+        username: validateUsername(unvalidated.username),\n+        comment: validateComment(unvalidated.comment),\n+        fileswid: validateSwid(unvalidated.fileswid),\n+        project: validateProject(unvalidated.project)\n+      };\n     } catch (e) {\n       if (e instanceof ValidationError) {\n         validationErrors.push({ fileswid: unvalidated.fileswid, error: e.message });\n         return null;\n       } else {\n-        console.log(e.error);\n         throw e;\n       } \n     }\n-    return validated;\n+  });\n+\n+  return { validated: validatedParams, errors: validationErrors };\n+}\n+\n+\n+/** success returns a single File Provenance Report result */\n+function getSingleFprResult (swid) {\n+  return new Promise((resolve, reject) => {\n+    fpr.get('SELECT * FROM fpr WHERE fileswid = ?', [swid], (err, data) => {\n+      if (err) reject(generateError(500, err));\n+      resolve((data ? data : {}));\n+    });\n+  });\n+}\n+\n+/** success returns a single FileQC result */\n+function getSingleFqcResult (swid) {\n+  return new Promise((resolve, reject) => {\n+    const sql = 'SELECT * FROM FileQc WHERE fileswid = $1';\n+    pg.any(sql, [swid])\n+      .then(data => { \n+        if (!data || data.length == 0) resolve({ fileqc: {}, errors: [] });\n+        resolve({ fileqc: data[0], errors: [] });\n+      })\n+      .catch(err => {\n+        logger.error(err);\n+        reject(generateError(500, 'Error retrieving record'));\n+      });\n+  });\n+}\n+\n+/** success returns an array of File Provenance results filtered by the given project */\n+function getFprResultsByProject (project) {\n+  return new Promise((resolve, reject) => {\n+    fpr.all('SELECT * FROM fpr WHERE project = ? ORDER BY fileswid ASC', [project], (err, data) => {","path":"components/fileqcs/fileQcsController.js","position":424,"original_position":424,"commit_id":"959d727ba489a8f91405ab212ab32342553df0a4","original_commit_id":"959d727ba489a8f91405ab212ab32342553df0a4","user":{"login":"apmasell","id":272423,"avatar_url":"https://avatars2.githubusercontent.com/u/272423?v=4","gravatar_id":"","url":"https://api.github.com/users/apmasell","html_url":"https://github.com/apmasell","followers_url":"https://api.github.com/users/apmasell/followers","following_url":"https://api.github.com/users/apmasell/following{/other_user}","gists_url":"https://api.github.com/users/apmasell/gists{/gist_id}","starred_url":"https://api.github.com/users/apmasell/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/apmasell/subscriptions","organizations_url":"https://api.github.com/users/apmasell/orgs","repos_url":"https://api.github.com/users/apmasell/repos","events_url":"https://api.github.com/users/apmasell/events{/privacy}","received_events_url":"https://api.github.com/users/apmasell/received_events","type":"User","site_admin":false},"body":"Some of these `?` and some are `$1` and some are `${x}`. Is there a reason?","created_at":"2017-11-30T15:03:29Z","updated_at":"2017-11-30T15:08:00Z","html_url":"https://github.com/oicr-gsi/issitoq/pull/1#discussion_r154099935","pull_request_url":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/1","author_association":"NONE","_links":{"self":{"href":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/comments/154099935"},"html":{"href":"https://github.com/oicr-gsi/issitoq/pull/1#discussion_r154099935"},"pull_request":{"href":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/1"}}},"pull_request":{"url":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/1","id":155471169,"html_url":"https://github.com/oicr-gsi/issitoq/pull/1","diff_url":"https://github.com/oicr-gsi/issitoq/pull/1.diff","patch_url":"https://github.com/oicr-gsi/issitoq/pull/1.patch","issue_url":"https://api.github.com/repos/oicr-gsi/issitoq/issues/1","number":1,"state":"open","locked":false,"title":"Add sqlite and logging and more...","user":{"login":"callunity","id":8324996,"avatar_url":"https://avatars2.githubusercontent.com/u/8324996?v=4","gravatar_id":"","url":"https://api.github.com/users/callunity","html_url":"https://github.com/callunity","followers_url":"https://api.github.com/users/callunity/followers","following_url":"https://api.github.com/users/callunity/following{/other_user}","gists_url":"https://api.github.com/users/callunity/gists{/gist_id}","starred_url":"https://api.github.com/users/callunity/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/callunity/subscriptions","organizations_url":"https://api.github.com/users/callunity/orgs","repos_url":"https://api.github.com/users/callunity/repos","events_url":"https://api.github.com/users/callunity/events{/privacy}","received_events_url":"https://api.github.com/users/callunity/received_events","type":"User","site_admin":false},"body":"","created_at":"2017-11-29T22:34:14Z","updated_at":"2017-11-30T15:08:00Z","closed_at":null,"merged_at":null,"merge_commit_sha":"3a1c94b47aa013fcb209ea7d14e7a3e010b72f87","assignee":null,"assignees":[],"requested_reviewers":[],"milestone":null,"commits_url":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/1/commits","review_comments_url":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/1/comments","review_comment_url":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/comments{/number}","comments_url":"https://api.github.com/repos/oicr-gsi/issitoq/issues/1/comments","statuses_url":"https://api.github.com/repos/oicr-gsi/issitoq/statuses/959d727ba489a8f91405ab212ab32342553df0a4","head":{"label":"oicr-gsi:add_sqlite","ref":"add_sqlite","sha":"959d727ba489a8f91405ab212ab32342553df0a4","user":{"login":"oicr-gsi","id":4701585,"avatar_url":"https://avatars0.githubusercontent.com/u/4701585?v=4","gravatar_id":"","url":"https://api.github.com/users/oicr-gsi","html_url":"https://github.com/oicr-gsi","followers_url":"https://api.github.com/users/oicr-gsi/followers","following_url":"https://api.github.com/users/oicr-gsi/following{/other_user}","gists_url":"https://api.github.com/users/oicr-gsi/gists{/gist_id}","starred_url":"https://api.github.com/users/oicr-gsi/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/oicr-gsi/subscriptions","organizations_url":"https://api.github.com/users/oicr-gsi/orgs","repos_url":"https://api.github.com/users/oicr-gsi/repos","events_url":"https://api.github.com/users/oicr-gsi/events{/privacy}","received_events_url":"https://api.github.com/users/oicr-gsi/received_events","type":"Organization","site_admin":false},"repo":{"id":110716034,"name":"issitoq","full_name":"oicr-gsi/issitoq","owner":{"login":"oicr-gsi","id":4701585,"avatar_url":"https://avatars0.githubusercontent.com/u/4701585?v=4","gravatar_id":"","url":"https://api.github.com/users/oicr-gsi","html_url":"https://github.com/oicr-gsi","followers_url":"https://api.github.com/users/oicr-gsi/followers","following_url":"https://api.github.com/users/oicr-gsi/following{/other_user}","gists_url":"https://api.github.com/users/oicr-gsi/gists{/gist_id}","starred_url":"https://api.github.com/users/oicr-gsi/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/oicr-gsi/subscriptions","organizations_url":"https://api.github.com/users/oicr-gsi/orgs","repos_url":"https://api.github.com/users/oicr-gsi/repos","events_url":"https://api.github.com/users/oicr-gsi/events{/privacy}","received_events_url":"https://api.github.com/users/oicr-gsi/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/oicr-gsi/issitoq","description":null,"fork":false,"url":"https://api.github.com/repos/oicr-gsi/issitoq","forks_url":"https://api.github.com/repos/oicr-gsi/issitoq/forks","keys_url":"https://api.github.com/repos/oicr-gsi/issitoq/keys{/key_id}","collaborators_url":"https://api.github.com/repos/oicr-gsi/issitoq/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/oicr-gsi/issitoq/teams","hooks_url":"https://api.github.com/repos/oicr-gsi/issitoq/hooks","issue_events_url":"https://api.github.com/repos/oicr-gsi/issitoq/issues/events{/number}","events_url":"https://api.github.com/repos/oicr-gsi/issitoq/events","assignees_url":"https://api.github.com/repos/oicr-gsi/issitoq/assignees{/user}","branches_url":"https://api.github.com/repos/oicr-gsi/issitoq/branches{/branch}","tags_url":"https://api.github.com/repos/oicr-gsi/issitoq/tags","blobs_url":"https://api.github.com/repos/oicr-gsi/issitoq/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/oicr-gsi/issitoq/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/oicr-gsi/issitoq/git/refs{/sha}","trees_url":"https://api.github.com/repos/oicr-gsi/issitoq/git/trees{/sha}","statuses_url":"https://api.github.com/repos/oicr-gsi/issitoq/statuses/{sha}","languages_url":"https://api.github.com/repos/oicr-gsi/issitoq/languages","stargazers_url":"https://api.github.com/repos/oicr-gsi/issitoq/stargazers","contributors_url":"https://api.github.com/repos/oicr-gsi/issitoq/contributors","subscribers_url":"https://api.github.com/repos/oicr-gsi/issitoq/subscribers","subscription_url":"https://api.github.com/repos/oicr-gsi/issitoq/subscription","commits_url":"https://api.github.com/repos/oicr-gsi/issitoq/commits{/sha}","git_commits_url":"https://api.github.com/repos/oicr-gsi/issitoq/git/commits{/sha}","comments_url":"https://api.github.com/repos/oicr-gsi/issitoq/comments{/number}","issue_comment_url":"https://api.github.com/repos/oicr-gsi/issitoq/issues/comments{/number}","contents_url":"https://api.github.com/repos/oicr-gsi/issitoq/contents/{+path}","compare_url":"https://api.github.com/repos/oicr-gsi/issitoq/compare/{base}...{head}","merges_url":"https://api.github.com/repos/oicr-gsi/issitoq/merges","archive_url":"https://api.github.com/repos/oicr-gsi/issitoq/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/oicr-gsi/issitoq/downloads","issues_url":"https://api.github.com/repos/oicr-gsi/issitoq/issues{/number}","pulls_url":"https://api.github.com/repos/oicr-gsi/issitoq/pulls{/number}","milestones_url":"https://api.github.com/repos/oicr-gsi/issitoq/milestones{/number}","notifications_url":"https://api.github.com/repos/oicr-gsi/issitoq/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/oicr-gsi/issitoq/labels{/name}","releases_url":"https://api.github.com/repos/oicr-gsi/issitoq/releases{/id}","deployments_url":"https://api.github.com/repos/oicr-gsi/issitoq/deployments","created_at":"2017-11-14T16:30:58Z","updated_at":"2017-11-14T19:53:46Z","pushed_at":"2017-11-29T22:34:14Z","git_url":"git://github.com/oicr-gsi/issitoq.git","ssh_url":"git@github.com:oicr-gsi/issitoq.git","clone_url":"https://github.com/oicr-gsi/issitoq.git","svn_url":"https://github.com/oicr-gsi/issitoq","homepage":null,"size":75746,"stargazers_count":0,"watchers_count":0,"language":"JavaScript","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"open_issues_count":1,"license":{"key":"mit","name":"MIT License","spdx_id":"MIT","url":"https://api.github.com/licenses/mit"},"forks":0,"open_issues":1,"watchers":0,"default_branch":"master"}},"base":{"label":"oicr-gsi:master","ref":"master","sha":"a452b68bef0aeebc17ebd04ef3582d910cd4bc16","user":{"login":"oicr-gsi","id":4701585,"avatar_url":"https://avatars0.githubusercontent.com/u/4701585?v=4","gravatar_id":"","url":"https://api.github.com/users/oicr-gsi","html_url":"https://github.com/oicr-gsi","followers_url":"https://api.github.com/users/oicr-gsi/followers","following_url":"https://api.github.com/users/oicr-gsi/following{/other_user}","gists_url":"https://api.github.com/users/oicr-gsi/gists{/gist_id}","starred_url":"https://api.github.com/users/oicr-gsi/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/oicr-gsi/subscriptions","organizations_url":"https://api.github.com/users/oicr-gsi/orgs","repos_url":"https://api.github.com/users/oicr-gsi/repos","events_url":"https://api.github.com/users/oicr-gsi/events{/privacy}","received_events_url":"https://api.github.com/users/oicr-gsi/received_events","type":"Organization","site_admin":false},"repo":{"id":110716034,"name":"issitoq","full_name":"oicr-gsi/issitoq","owner":{"login":"oicr-gsi","id":4701585,"avatar_url":"https://avatars0.githubusercontent.com/u/4701585?v=4","gravatar_id":"","url":"https://api.github.com/users/oicr-gsi","html_url":"https://github.com/oicr-gsi","followers_url":"https://api.github.com/users/oicr-gsi/followers","following_url":"https://api.github.com/users/oicr-gsi/following{/other_user}","gists_url":"https://api.github.com/users/oicr-gsi/gists{/gist_id}","starred_url":"https://api.github.com/users/oicr-gsi/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/oicr-gsi/subscriptions","organizations_url":"https://api.github.com/users/oicr-gsi/orgs","repos_url":"https://api.github.com/users/oicr-gsi/repos","events_url":"https://api.github.com/users/oicr-gsi/events{/privacy}","received_events_url":"https://api.github.com/users/oicr-gsi/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/oicr-gsi/issitoq","description":null,"fork":false,"url":"https://api.github.com/repos/oicr-gsi/issitoq","forks_url":"https://api.github.com/repos/oicr-gsi/issitoq/forks","keys_url":"https://api.github.com/repos/oicr-gsi/issitoq/keys{/key_id}","collaborators_url":"https://api.github.com/repos/oicr-gsi/issitoq/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/oicr-gsi/issitoq/teams","hooks_url":"https://api.github.com/repos/oicr-gsi/issitoq/hooks","issue_events_url":"https://api.github.com/repos/oicr-gsi/issitoq/issues/events{/number}","events_url":"https://api.github.com/repos/oicr-gsi/issitoq/events","assignees_url":"https://api.github.com/repos/oicr-gsi/issitoq/assignees{/user}","branches_url":"https://api.github.com/repos/oicr-gsi/issitoq/branches{/branch}","tags_url":"https://api.github.com/repos/oicr-gsi/issitoq/tags","blobs_url":"https://api.github.com/repos/oicr-gsi/issitoq/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/oicr-gsi/issitoq/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/oicr-gsi/issitoq/git/refs{/sha}","trees_url":"https://api.github.com/repos/oicr-gsi/issitoq/git/trees{/sha}","statuses_url":"https://api.github.com/repos/oicr-gsi/issitoq/statuses/{sha}","languages_url":"https://api.github.com/repos/oicr-gsi/issitoq/languages","stargazers_url":"https://api.github.com/repos/oicr-gsi/issitoq/stargazers","contributors_url":"https://api.github.com/repos/oicr-gsi/issitoq/contributors","subscribers_url":"https://api.github.com/repos/oicr-gsi/issitoq/subscribers","subscription_url":"https://api.github.com/repos/oicr-gsi/issitoq/subscription","commits_url":"https://api.github.com/repos/oicr-gsi/issitoq/commits{/sha}","git_commits_url":"https://api.github.com/repos/oicr-gsi/issitoq/git/commits{/sha}","comments_url":"https://api.github.com/repos/oicr-gsi/issitoq/comments{/number}","issue_comment_url":"https://api.github.com/repos/oicr-gsi/issitoq/issues/comments{/number}","contents_url":"https://api.github.com/repos/oicr-gsi/issitoq/contents/{+path}","compare_url":"https://api.github.com/repos/oicr-gsi/issitoq/compare/{base}...{head}","merges_url":"https://api.github.com/repos/oicr-gsi/issitoq/merges","archive_url":"https://api.github.com/repos/oicr-gsi/issitoq/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/oicr-gsi/issitoq/downloads","issues_url":"https://api.github.com/repos/oicr-gsi/issitoq/issues{/number}","pulls_url":"https://api.github.com/repos/oicr-gsi/issitoq/pulls{/number}","milestones_url":"https://api.github.com/repos/oicr-gsi/issitoq/milestones{/number}","notifications_url":"https://api.github.com/repos/oicr-gsi/issitoq/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/oicr-gsi/issitoq/labels{/name}","releases_url":"https://api.github.com/repos/oicr-gsi/issitoq/releases{/id}","deployments_url":"https://api.github.com/repos/oicr-gsi/issitoq/deployments","created_at":"2017-11-14T16:30:58Z","updated_at":"2017-11-14T19:53:46Z","pushed_at":"2017-11-29T22:34:14Z","git_url":"git://github.com/oicr-gsi/issitoq.git","ssh_url":"git@github.com:oicr-gsi/issitoq.git","clone_url":"https://github.com/oicr-gsi/issitoq.git","svn_url":"https://github.com/oicr-gsi/issitoq","homepage":null,"size":75746,"stargazers_count":0,"watchers_count":0,"language":"JavaScript","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":0,"mirror_url":null,"archived":false,"open_issues_count":1,"license":{"key":"mit","name":"MIT License","spdx_id":"MIT","url":"https://api.github.com/licenses/mit"},"forks":0,"open_issues":1,"watchers":0,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/1"},"html":{"href":"https://github.com/oicr-gsi/issitoq/pull/1"},"issue":{"href":"https://api.github.com/repos/oicr-gsi/issitoq/issues/1"},"comments":{"href":"https://api.github.com/repos/oicr-gsi/issitoq/issues/1/comments"},"review_comments":{"href":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/1/comments"},"review_comment":{"href":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/oicr-gsi/issitoq/pulls/1/commits"},"statuses":{"href":"https://api.github.com/repos/oicr-gsi/issitoq/statuses/959d727ba489a8f91405ab212ab32342553df0a4"}},"author_association":"COLLABORATOR"}}
{"actor":{"display_login":"apmasell"}}