From 1da19b3b8a7a25a2a66c0600748d8e90f7d678d4 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 20 Jan 2021 16:59:52 -0800 Subject: [PATCH 1/2] Introduce issue repository + skeleton. In particular, this encapsulates data loading, defines an issue + issue repository domain layer, encapsulates GraphQL querying, and slightly improves async function management via promises so that we explicitly handle success/failure. Also, fill in TODOs for future functionality that we need to add. --- project-dashboard/data-loader.js | 34 +++++++++ project-dashboard/domain.js | 95 +++++++++++++++++++++++++ project-dashboard/graphql.js | 117 ++++++++++++++++++++++++++++++ project-dashboard/index.html | 3 + project-dashboard/scripts.js | 118 ++++--------------------------- 5 files changed, 264 insertions(+), 103 deletions(-) create mode 100644 project-dashboard/data-loader.js create mode 100644 project-dashboard/domain.js create mode 100644 project-dashboard/graphql.js diff --git a/project-dashboard/data-loader.js b/project-dashboard/data-loader.js new file mode 100644 index 0000000..cdd955c --- /dev/null +++ b/project-dashboard/data-loader.js @@ -0,0 +1,34 @@ +// TODO(BenHenning): Add local storage caching support for data. + +class DataLoader { + graphqlFetcher; + issueRepository = null; + _ptiQueryResults = {}; + _allIssueQueryResults = {}; + _milestoneResults = {}; + + constructor(graphqlFetcher) { + this.graphqlFetcher = graphqlFetcher; + } + + get isLoaded() { + return this.issueRepository != null; + } + + async load() { + // Wait for all queries to finish (all must succeed). + const dataLoader = this; + return Promise.all([ + this.graphqlFetcher.queryPtis(), + this.graphqlFetcher.queryAllIssues(), + this.graphqlFetcher.queryMilestones() + ]).then(function(resultsArray) { + dataLoader._ptiQueryResults = resultsArray[0]; + dataLoader._allIssueQueryResults = resultsArray[1]; + dataLoader._milestoneResults = resultsArray[2]; + dataLoader.issueRepository = IssueRepository.createFromGraphql( + dataLoader._ptiQueryResults, dataLoader._allIssueQueryResults); + return true; + }); + } +}; diff --git a/project-dashboard/domain.js b/project-dashboard/domain.js new file mode 100644 index 0000000..97d57f0 --- /dev/null +++ b/project-dashboard/domain.js @@ -0,0 +1,95 @@ +String.prototype.normalize = function() { + return this.trim().replaceAll(/\s{2,}/, " "); +}; + +let _parseIssuesFromTitle = function(issueTitle, expectedPrefixes) { + const pattern = new RegExp( + `\\[(${expectedPrefixes.join("|")}):(#\\d+(,?#\\d+)*)\\]`, 'i'); + const issueMatches = issueTitle.replaceAll(/\s/g, "").match(pattern); + if (issueMatches == null || issueMatches.length < 3) { + return []; // No issues are contained in the issue title. + } + // Retrieve just the group which corresponds to the list of issues. + const issueNumbers = issueMatches[2].split(","); + return issueNumbers.map(issueReference => issueReference.substr(1)); +}; + +let _parseBlockingIssueFromTitle = function(issueTitle) { + return _parseIssuesFromTitle(issueTitle, ["blocking"]); +}; + +let _parseBlockedIssueFromTitle = function(issueTitle) { + return _parseIssuesFromTitle(issueTitle, ["blocked", "blockedby"]); +}; + +class Issue { + id; + title; + bodyText; + url; + blockingIssueNumbers; + blockedIssueNumbers; + projectIds; + projectStages; + milestoneId; + + constructor( + id, title, bodyText, url, blockingIssueNumbers, blockedIssueNumbers, + projectIds, projectStages, milestoneId) { + this.id = id; + this.title = title; + this.bodyText = bodyText; + this.url = url; + this.blockingIssueNumbers = blockingIssueNumbers; + this.blockedIssueNumbers = blockedIssueNumbers; + this.projectIds = projectIds; + this.projectStages = projectStages; + this.milestoneId = milestoneId; + } + + static createFromGraphql(issueObject) { + const id = issueObject.number; + const title = issueObject.title; + const bodyText = issueObject.bodyText; + const url = issueObject.url; + const blockingIssueNumbers = _parseBlockingIssueFromTitle(title); + const blockedIssueNumbers = _parseBlockedIssueFromTitle(title); + const projectCards = (issueObject.projectCards || {}).nodes || []; + const projectIds = projectCards.map(card => card.project.number); + const projectStages = projectCards.map(card => card.column.name); + const milestoneId = (issueObject.milestone || {}).number; + return new Issue( + id, title, bodyText, url, blockingIssueNumbers, blockedIssueNumbers, + projectIds, projectStages, milestoneId); + } +} + +class IssueRepository { + issueMap; + + constructor(issueMap) { + this.issueMap = issueMap; + } + + retrieveIssue(number) { + return this.issueMap.get(number); + } + + static createFromGraphql(ptisObject, allIssuesObject) { + const ptiIssues = ptisObject.nodes.map( + ptiNode => Issue.createFromGraphql(ptiNode)); + const ptiIssueIds = ptiIssues.map(ptiIssue => ptiIssue.id); + const nonPtiIssueNodes = allIssuesObject.nodes.filter( + node => !ptiIssueIds.includes(node.number)); + const nonPtiIssues = nonPtiIssueNodes.map( + issueNode => Issue.createFromGraphql(issueNode)); + const issueMap = new Map(); + for (const ptiIssue of ptiIssues) { + issueMap.set(ptiIssue.id, ptiIssue); + } + for (const nonPtiIssue of nonPtiIssues) { + issueMap.set(nonPtiIssue.id, nonPtiIssue); + } + return new IssueRepository(issueMap); + }; +}; diff --git a/project-dashboard/graphql.js b/project-dashboard/graphql.js new file mode 100644 index 0000000..8121f81 --- /dev/null +++ b/project-dashboard/graphql.js @@ -0,0 +1,117 @@ +const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; +const PTI_ISSUE_LABEL = "Type: PTI"; + +class GraphqlFetcher { + _graphqlClient; + repositoryOwner; + repositoryName; + + constructor(graphqlClient, repositoryOwner, repositoryName) { + this._graphqlClient = graphqlClient; + this.repositoryOwner = repositoryOwner; + this.repositoryName = repositoryName; + } + + // TODO(BenHenning): add support for automatic pagination retrieval. + + async queryPtis() { + const ptiQuery = this._graphqlClient(`query($repo_name: String!, $repo_owner: String!, $labels: [String!], $first: Int, $after: String) { + repository(name: $repo_name, owner: $repo_owner) { + ptis: issues(labels: $labels, first: $first, after: $after) { + totalCount + nodes { + bodyText + number + milestone { + number + } + title + url + projectCards(first: 10) { + nodes { + project { + name + number + } + column { + name + } + } + } + } + pageInfo { + hasNextPage + } + } + } + }`); + return ptiQuery({ + repo_name: this.repositoryName, + repo_owner: this.repositoryOwner, + labels: PTI_ISSUE_LABEL, + first: 100 + }).then(result => result.repository.ptis); + } + + async queryAllIssues() { + const allIssuesQuery = this._graphqlClient(`query($repo_name: String!, $repo_owner: String!, $first: Int, $after: String) { + repository(name: $repo_name, owner: $repo_owner) { + all_issues: issues(first: $first, after: $after) { + totalCount + nodes { + number + title + url + milestone { + number + } + } + pageInfo { + hasNextPage + } + } + } + }`); + return allIssuesQuery({ + repo_name: this.repositoryName, + repo_owner: this.repositoryOwner, + first: 100 + }).then(result => result.repository.all_issues); + } + + async queryMilestones() { + const milestonesQuery = this._graphqlClient(`query($repo_name: String!, $repo_owner: String!, $first: Int, $after: String) { + repository(name: $repo_name, owner: $repo_owner) { + milestones: milestones(first: $first, after: $after) { + totalCount + pageInfo { + hasNextPage + } + nodes { + dueOn + number + title + url + progressPercentage + } + } + } + }`); + return milestonesQuery({ + repo_name: this.repositoryName, + repo_owner: this.repositoryOwner, + first: 100 + }).then(result => result.repository.milestones); + } + + static initialize(accessToken, repositoryOwner, repositoryName) { + const graphqlClient = graphql("https://api.github.com/graphql", { + method: "POST", + asJSON: true, + headers: { + "Authorization": `bearer ${accessToken}` + }, + }); + return new GraphqlFetcher(graphqlClient, repositoryOwner, repositoryName); + } +} diff --git a/project-dashboard/index.html b/project-dashboard/index.html index ec0476a..5d64156 100644 --- a/project-dashboard/index.html +++ b/project-dashboard/index.html @@ -7,6 +7,9 @@

Project Dashboard

+ + + diff --git a/project-dashboard/scripts.js b/project-dashboard/scripts.js index 38e3b28..6441904 100644 --- a/project-dashboard/scripts.js +++ b/project-dashboard/scripts.js @@ -1,103 +1,15 @@ -(async function () { - const PAT = ''; - const graph = graphql('https://api.github.com/graphql', { - method: 'POST', - asJSON: true, - headers: { - 'Authorization': `bearer ${PAT}` - }, - }); - - const repo_name = 'test-project-management-data'; - const repo_owner = 'BenHenning'; - - let repository_query = graph(`query($repo_name: String!, $repo_owner: String!, $labels: [String!], $first: Int, $after: String) { - repository(name: $repo_name, owner: $repo_owner) { - ptis: issues(labels: $labels, first: $first, after: $after) { - totalCount - nodes { - bodyText - bodyUrl - number - milestone { - title - number - } - title - url - projectCards(first: 10) { - nodes { - project { - name - number - } - column { - name - } - } - } - } - pageInfo { - hasNextPage - } - } - } - }`); - - let all_issues_query = graph(`query($repo_name: String!, $repo_owner: String!, $first: Int, $after: String) { - repository(name: $repo_name, owner: $repo_owner) { - all_issues: issues(first: $first, after: $after) { - totalCount - nodes { - number - title - milestone { - number - } - } - pageInfo { - hasNextPage - } - } - } - }`); - - let milestones_query = graph(`query($repo_name: String!, $repo_owner: String!, $first: Int, $after: String) { - repository(name: $repo_name, owner: $repo_owner) { - milestones(first: $first, after: $after) { - totalCount - pageInfo { - hasNextPage - } - nodes { - dueOn - number - title - url - progressPercentage - } - } - } - }`); - - let repositories = await repository_query({ - repo_name, - repo_owner, - labels: 'Type: PTI', - first: 100, - }); - - let all_issues = await all_issues_query({ - repo_name, - repo_owner, - first: 100, - }); - - let milestones = await milestones_query({ - repo_name, - repo_owner, - first: 100, - }); - - console.log(repositories, all_issues, milestones); -})(); +// TODO(BenHenning): Add input & local storage support for the PAT. +const personalAccessToken = ""; +// TODO(BenHenning): Switch the repository over to Oppia Android once ready. +const repoOwner = "BenHenning"; +const repoName = "test-project-management-data"; + +const graphqlFetcher = GraphqlFetcher.initialize( + personalAccessToken, repoOwner, repoName); +const dataLoader = new DataLoader(graphqlFetcher); +dataLoader.load().then(function(succeeded) { + console.log("Successfully loaded repository", dataLoader.issueRepository); +}).catch(function(error) { + // TODO(BenHenning): Add dashboard error messaging. + console.log("Failed to execute GraphQL retrieval:", error); +}); From cab2dd3f56762a41eb04e3388cafc3ce4bd7a3a6 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 20 Jan 2021 21:44:11 -0800 Subject: [PATCH 2/2] Spec out more domain objects + documentation. Spec out more of the domain objects needed in the domain layer, add some documentation for issues to establish a document style & level of detail, and add a high-level diagram of the organizational break-down. --- project-dashboard/data-loader.js | 5 +- project-dashboard/domain.js | 184 ++++++++++++++++++++++++++++--- 2 files changed, 172 insertions(+), 17 deletions(-) diff --git a/project-dashboard/data-loader.js b/project-dashboard/data-loader.js index cdd955c..9c1a9d5 100644 --- a/project-dashboard/data-loader.js +++ b/project-dashboard/data-loader.js @@ -1,4 +1,7 @@ -// TODO(BenHenning): Add local storage caching support for data. +// TODO(BenHenning): Add local storage caching support for data. Note +// https://stackoverflow.com/a/56150320 for stringifying maps. Also, consider +// adding a version so that newer versions of the dashboard force a refresh of +// the cache. class DataLoader { graphqlFetcher; diff --git a/project-dashboard/domain.js b/project-dashboard/domain.js index 97d57f0..73a35f4 100644 --- a/project-dashboard/domain.js +++ b/project-dashboard/domain.js @@ -1,6 +1,55 @@ -String.prototype.normalize = function() { - return this.trim().replaceAll(/\s{2,}/, " "); -}; +/** + * @fileoverview General definitions for the domain layer of the project + * dashboard. See the following diagram for an organizational breakdown of all + * the different structures present in the layer, their relationships, and how + * they correspond to data defined on GitHub. + * + * +---------------+ + * | Product | + * +-----------------| (GitHub Repo) |---------------------+ + * | +-------+-------+ | + * | | | + * | | | + * +--------v--------+ +----------v----------+ +-------------v----------+ + * | Product Board | | Product | | | + * | (GitHub | | Milestones | | Team Boards | + * | Project Board) | | (GitHub Milestones) | |(GitHub Project Boards) | + * | | | | | | + * +-----------------+ +---------------------+ +------------------------+ + * | | | | | + * | | | | | + * | +----v------------+ | | +----------------v----+ + * | | Bugs + Misc. | | | | Bugs + Misc. | + * | | (GitHub Issues) | | | | (GitHub Issues) | + * | +-----------------+ | | +---------------------+ + * | | | + * +------------------------------------------+ + * | + * | + * +-------------|-------------+ + * |Projects | | + * | | | + * | | | + * | +----------v----------+ | + * | | PTI: Project | | + * | | Tracking Issue | | + * | | (GitHub Issue) | | + * | +---------------------+ | + * | | | + * | | | + * | +----------v----------+ | + * | | Project Milestones | | + * | | (GitHub Milestones) | | + * | +---------------------+ | + * | | | + * | | | + * | +----------v----------+ | + * | | Bugs + Tasks | | + * | | (GitHub Issues) | | + * | +---------------------+ | + * | | + * +---------------------------+ + */ let _parseIssuesFromTitle = function(issueTitle, expectedPrefixes) { const pattern = new RegExp( @@ -22,31 +71,50 @@ let _parseBlockedIssueFromTitle = function(issueTitle) { return _parseIssuesFromTitle(issueTitle, ["blocked", "blockedby"]); }; +/** Represents an issue filed on GitHub. */ class Issue { + /** {String} - The issue number. */ id; + + /** {String} - The title of the issue. */ title; + + /** {String} - The body text of the issue's opening comment. */ bodyText; + + /** {String} - A URL directly to the issue on GitHub. */ url; + + /** + * {Array} - An array of strings corresponding to issue numbers that are + * blocking this issue. + */ blockingIssueNumbers; + + /** + * {Array} - An array of strings corresponding to issue numbers that are + * blocked by this issue. + */ blockedIssueNumbers; - projectIds; - projectStages; - milestoneId; constructor( - id, title, bodyText, url, blockingIssueNumbers, blockedIssueNumbers, - projectIds, projectStages, milestoneId) { + id, title, bodyText, url, blockingIssueNumbers, blockedIssueNumbers) { this.id = id; this.title = title; this.bodyText = bodyText; this.url = url; this.blockingIssueNumbers = blockingIssueNumbers; this.blockedIssueNumbers = blockedIssueNumbers; - this.projectIds = projectIds; - this.projectStages = projectStages; - this.milestoneId = milestoneId; } + /** + * Creates a new issue object from a GraphQL-derived object. Note that this is + * heavily dependent on the query used to fetch the GraphQL data. + * + * @param {Object} issueObject - An object containing GraphQL query results + * for a single issue. + * @return {Issue} - The derived issue object. + **/ static createFromGraphql(issueObject) { const id = issueObject.number; const title = issueObject.title; @@ -55,26 +123,47 @@ class Issue { const blockingIssueNumbers = _parseBlockingIssueFromTitle(title); const blockedIssueNumbers = _parseBlockedIssueFromTitle(title); const projectCards = (issueObject.projectCards || {}).nodes || []; - const projectIds = projectCards.map(card => card.project.number); - const projectStages = projectCards.map(card => card.column.name); - const milestoneId = (issueObject.milestone || {}).number; + // const projectIds = projectCards.map(card => card.project.number); + // const projectStages = projectCards.map(card => card.column.name); + // const milestoneId = (issueObject.milestone || {}).number; return new Issue( - id, title, bodyText, url, blockingIssueNumbers, blockedIssueNumbers, - projectIds, projectStages, milestoneId); + id, title, bodyText, url, blockingIssueNumbers, blockedIssueNumbers); } } +/** + * Represents a repository used for retrieving & performing operations on + * issues. + */ class IssueRepository { + /** {Map} - A map from issue ID (String) to Issue. */ issueMap; constructor(issueMap) { this.issueMap = issueMap; } + /** + * Returns an [Issue] corresponding to the specified issue number. + * + * @param {String} number - The issue number. + * @return {Issue} - The retrieved issue, or null if there is none + * corresponding to the specified number. + */ retrieveIssue(number) { return this.issueMap.get(number); } + /** + * Creates a new issue repository from the results of specific GraphQL + * queries. + * + * @param {Object} ptisObject - An object containing GraphQL query results for + * all project tracking issues (PTIs) in the connected repository. + * @param {Object} allIssuesObject - An object containing GraphQL query results + * for all issues in the connected repository. + * @return {IssueRepository} - The derived issue repository object. + **/ static createFromGraphql(ptisObject, allIssuesObject) { const ptiIssues = ptisObject.nodes.map( ptiNode => Issue.createFromGraphql(ptiNode)); @@ -93,3 +182,66 @@ class IssueRepository { return new IssueRepository(issueMap); }; }; + +/** Corresponds to a milestone that's part of a broader project. */ +class ProjectMilestone { + milestoneId; + estimatedDurationDays; +} + +class Project { + id; + title; + description; + projectLead; + productManagers; + technicalLead; + productStage; + productMilestoneId; + teamId; + projectMilestones; + estimatedStartDate; + actualStartDate; + roughEstimatedProjectDurationDays; + estimatedProductDesignDurationDays; + estimatedTechnicalDesignDurationDays; + estimatedVerificationDurationDays; + actualFinishDate; +} + +class ProjectDatabase { + projectMap; + + // TODO(jasamina13): Add support for: + // - Retrieving dependent issues for a project. + // - Retrieving dependent projects for a project. +} + +export const MilestoneType = Object.freeze({ + UNKNOWN: 0, + PRODUCT: 1, + PROJECT: 2 +}); + +class Milestone { + id; + url; + title; + description; + progressPercentage; + issueIds; +} + +class MilestoneRepository { + milestoneMap; +} + +class Team { + id; + projectBoardUrl; + teamName; +} + +class TeamRepository { + teamMap; +}