diff --git a/server/plugin/api.go b/server/plugin/api.go index 5f69b6b52..a54b2dc9b 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -154,6 +154,8 @@ func (p *Plugin) initializeAPI() { apiRouter.HandleFunc("/labels", p.checkAuth(p.attachUserContext(p.getLabels), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/milestones", p.checkAuth(p.attachUserContext(p.getMilestones), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/assignees", p.checkAuth(p.attachUserContext(p.getAssignees), ResponseTypePlain)).Methods(http.MethodGet) + apiRouter.HandleFunc("/organizations", p.checkAuth(p.attachUserContext(p.getOrganizations), ResponseTypePlain)).Methods(http.MethodGet) + apiRouter.HandleFunc("/repos_by_org", p.checkAuth(p.attachUserContext(p.getReposByOrg), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/repositories", p.checkAuth(p.attachUserContext(p.getRepositories), ResponseTypePlain)).Methods(http.MethodGet) apiRouter.HandleFunc("/settings", p.checkAuth(p.attachUserContext(p.updateSettings), ResponseTypePlain)).Methods(http.MethodPost) apiRouter.HandleFunc("/issue", p.checkAuth(p.attachUserContext(p.getIssueByNumber), ResponseTypePlain)).Methods(http.MethodGet) @@ -1186,10 +1188,29 @@ func (p *Plugin) getMilestones(c *UserContext, w http.ResponseWriter, r *http.Re p.writeJSON(w, allMilestones) } -func getRepositoryList(c context.Context, userName string, githubClient *github.Client, opt github.ListOptions) ([]*github.Repository, error) { +func getOrganizationList(c context.Context, userName string, githubClient *github.Client, opt github.ListOptions) ([]*github.Organization, error) { + var allOrgs []*github.Organization + for { + orgs, resp, err := githubClient.Organizations.List(c, userName, &opt) + if err != nil { + return nil, err + } + + allOrgs = append(allOrgs, orgs...) + if resp.NextPage == 0 { + break + } + + opt.Page = resp.NextPage + } + + return allOrgs, nil +} + +func getRepositoryList(c context.Context, userName string, githubClient *github.Client, opt github.RepositoryListOptions) ([]*github.Repository, error) { var allRepos []*github.Repository for { - repos, resp, err := githubClient.Repositories.List(c, userName, &github.RepositoryListOptions{ListOptions: opt}) + repos, resp, err := githubClient.Repositories.List(c, userName, &opt) if err != nil { return nil, err } @@ -1223,6 +1244,102 @@ func getRepositoryListByOrg(c context.Context, org string, githubClient *github. return allRepos, http.StatusOK, nil } +func (p *Plugin) getOrganizations(c *UserContext, w http.ResponseWriter, r *http.Request) { + var allOrgs []*github.Organization + org := p.getConfiguration().GitHubOrg + + if org == "" { + includeLoggedInUser := r.URL.Query().Get("includeLoggedInUser") + if includeLoggedInUser == "true" { + allOrgs = append(allOrgs, &github.Organization{Login: &c.GHInfo.GitHubUsername}) + } + githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) + orgList, err := getOrganizationList(c.Ctx, "", githubClient, github.ListOptions{PerPage: 50}) + if err != nil { + c.Log.WithError(err).Warnf("Failed to list organizations") + p.writeAPIError(w, &APIErrorResponse{Message: "Failed to fetch organizations", StatusCode: http.StatusInternalServerError}) + return + } + allOrgs = append(allOrgs, orgList...) + } else { + allOrgs = append(allOrgs, &github.Organization{Login: &org}) + } + // Only send required organizations to the client + type OrganizationResponse struct { + Login string `json:"login,omitempty"` + } + + resp := make([]*OrganizationResponse, len(allOrgs)) + for i, r := range allOrgs { + resp[i] = &OrganizationResponse{ + Login: r.GetLogin(), + } + } + + p.writeJSON(w, resp) +} + +func (p *Plugin) getReposByOrg(c *UserContext, w http.ResponseWriter, r *http.Request) { + githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) + + opt := github.ListOptions{PerPage: 50} + + org := r.URL.Query().Get("organization") + + if org == "" { + c.Log.Warnf("Organization query param is empty") + p.writeAPIError(w, &APIErrorResponse{Message: "Organization query is empty, must include organization name ", StatusCode: http.StatusBadRequest}) + return + } + + var allRepos []*github.Repository + var err error + var statusCode int + + // If an organization is the username of an authenticated user then return repos where the authenticated user is the owner + if org == c.GHInfo.GitHubUsername { + allRepos, err = getRepositoryList(c.Ctx, "", githubClient, github.RepositoryListOptions{ListOptions: opt, Affiliation: "owner"}) + if err != nil { + c.Log.WithError(err).Warnf("Failed to list repositories") + p.writeAPIError(w, &APIErrorResponse{Message: "Failed to fetch repositories", StatusCode: http.StatusInternalServerError}) + return + } + } else { + allRepos, statusCode, err = getRepositoryListByOrg(c.Ctx, org, githubClient, opt) + if err != nil { + if statusCode == http.StatusNotFound { + allRepos, err = getRepositoryList(c.Ctx, org, githubClient, github.RepositoryListOptions{ListOptions: opt}) + if err != nil { + c.Log.WithError(err).Warnf("Failed to list repositories") + p.writeAPIError(w, &APIErrorResponse{Message: "Failed to fetch repositories", StatusCode: http.StatusInternalServerError}) + return + } + } else { + c.Log.WithError(err).Warnf("Failed to list repositories") + p.writeAPIError(w, &APIErrorResponse{Message: "Failed to fetch repositories", StatusCode: statusCode}) + return + } + } + } + // Only send repositories which are part of the requested organization + type RepositoryResponse struct { + Name string `json:"name,omitempty"` + FullName string `json:"full_name,omitempty"` + Permissions map[string]bool `json:"permissions,omitempty"` + } + + resp := make([]*RepositoryResponse, len(allRepos)) + for i, r := range allRepos { + resp[i] = &RepositoryResponse{ + Name: r.GetName(), + FullName: r.GetFullName(), + Permissions: r.GetPermissions(), + } + } + + p.writeJSON(w, resp) +} + func (p *Plugin) getRepositories(c *UserContext, w http.ResponseWriter, r *http.Request) { githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) @@ -1234,7 +1351,7 @@ func (p *Plugin) getRepositories(c *UserContext, w http.ResponseWriter, r *http. opt := github.ListOptions{PerPage: 50} if org == "" { - allRepos, err = getRepositoryList(c.Ctx, "", githubClient, opt) + allRepos, err = getRepositoryList(c.Ctx, "", githubClient, github.RepositoryListOptions{ListOptions: opt}) if err != nil { c.Log.WithError(err).Warnf("Failed to list repositories") p.writeAPIError(w, &APIErrorResponse{Message: "Failed to fetch repositories", StatusCode: http.StatusInternalServerError}) @@ -1244,7 +1361,7 @@ func (p *Plugin) getRepositories(c *UserContext, w http.ResponseWriter, r *http. allRepos, statusCode, err = getRepositoryListByOrg(c.Ctx, org, githubClient, opt) if err != nil { if statusCode == http.StatusNotFound { - allRepos, err = getRepositoryList(c.Ctx, org, githubClient, opt) + allRepos, err = getRepositoryList(c.Ctx, org, githubClient, github.RepositoryListOptions{ListOptions: opt}) if err != nil { c.Log.WithError(err).Warnf("Failed to list repositories") p.writeAPIError(w, &APIErrorResponse{Message: "Failed to fetch repositories", StatusCode: http.StatusInternalServerError}) diff --git a/webapp/src/action_types/index.ts b/webapp/src/action_types/index.ts index 6ea61edf9..fc9647aa5 100644 --- a/webapp/src/action_types/index.ts +++ b/webapp/src/action_types/index.ts @@ -7,6 +7,8 @@ const {id: pluginId} = manifest; export default { RECEIVED_REPOSITORIES: pluginId + '_received_repositories', + RECEIVED_ORGANIZATIONS: pluginId + '_received_organizations', + RECEIVED_REPOSITORIES_BY_ORGANIZATION: pluginId + '_received_repositories_by_organization', RECEIVED_REVIEWS_DETAILS: pluginId + '_received_reviews_details', RECEIVED_YOUR_PRS_DETAILS: pluginId + '_received_your_prs_details', RECEIVED_SIDEBAR_CONTENT: pluginId + '_received_sidebar_content', diff --git a/webapp/src/actions/index.ts b/webapp/src/actions/index.ts index f4cc7aea3..44779883c 100644 --- a/webapp/src/actions/index.ts +++ b/webapp/src/actions/index.ts @@ -70,6 +70,52 @@ export function getReviewsDetails(prList: PrsDetailsData[]) { }; } +export function getOrgs() { + return async (dispatch: DispatchFunc) => { + let data; + try { + data = await Client.getOrganizations(); + } catch (error) { + return {error: data}; + } + + const connected = await checkAndHandleNotConnected(data)(dispatch); + if (!connected) { + return {error: data}; + } + + dispatch({ + type: ActionTypes.RECEIVED_ORGANIZATIONS, + data, + }); + + return {data}; + }; +} + +export function getReposByOrg(organization: string) { + return async (dispatch: DispatchFunc) => { + let data; + try { + data = await Client.getRepositoriesByOrganization(organization); + } catch (error) { + return {error: data}; + } + + const connected = await checkAndHandleNotConnected(data)(dispatch); + if (!connected) { + return {error: data}; + } + + dispatch({ + type: ActionTypes.RECEIVED_REPOSITORIES_BY_ORGANIZATION, + data, + }); + + return {data}; + }; +} + export function getRepos() { return async (dispatch: DispatchFunc) => { let data; diff --git a/webapp/src/client/client.js b/webapp/src/client/client.js index e381dbf5d..55631f159 100644 --- a/webapp/src/client/client.js +++ b/webapp/src/client/client.js @@ -31,10 +31,18 @@ export default class Client { return this.doPost(`${this.url}/user`, {user_id: userID}); } + getOrganizations = async () => { + return this.doGet(`${this.url}/organizations?includeLoggedInUser=true`); + } + getRepositories = async () => { return this.doGet(`${this.url}/repositories`); } + getRepositoriesByOrganization = async (organization) => { + return this.doGet(`${this.url}/repos_by_org?organization=${organization}`); + } + getLabels = async (repo) => { return this.doGet(`${this.url}/labels?repo=${repo}`); } diff --git a/webapp/src/components/github_repo_selector/github_repo_selector.jsx b/webapp/src/components/github_repo_selector/github_repo_selector.jsx index c6352203a..deaee3602 100644 --- a/webapp/src/components/github_repo_selector/github_repo_selector.jsx +++ b/webapp/src/components/github_repo_selector/github_repo_selector.jsx @@ -9,18 +9,21 @@ import ReactSelectSetting from 'components/react_select_setting'; const initialState = { invalid: false, error: null, + org: '', }; export default class GithubRepoSelector extends PureComponent { static propTypes = { - yourRepos: PropTypes.array.isRequired, + yourOrgs: PropTypes.array.isRequired, + yourReposByOrg: PropTypes.array, theme: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, value: PropTypes.string, addValidate: PropTypes.func, removeValidate: PropTypes.func, actions: PropTypes.shape({ - getRepos: PropTypes.func.isRequired, + getOrgs: PropTypes.func.isRequired, + getReposByOrg: PropTypes.func.isRequired, }).isRequired, }; @@ -30,25 +33,76 @@ export default class GithubRepoSelector extends PureComponent { } componentDidMount() { - this.props.actions.getRepos(); + this.props.actions.getOrgs(); } - onChange = (_, name) => { - const repo = this.props.yourRepos.find((r) => r.full_name === name); + componentDidUpdate(prevProps) { + if (prevProps.yourOrgs !== this.props.yourOrgs) { + if (this.props.yourOrgs.length) { + this.onChangeForOrg(0, this.props.yourOrgs[0].login); + } + } + } + + onChangeForOrg = (_, org) => { + if (this.state.org !== org) { + this.setState({org}); + this.props.actions.getReposByOrg(org); + this.props.onChange(null); + } + } + + onChangeForRepo = (_, name) => { + const repo = this.props.yourReposByOrg.find((r) => r.full_name === name); this.props.onChange({name, permissions: repo.permissions}); } render() { - const repoOptions = this.props.yourRepos.map((item) => ({value: item.full_name, label: item.full_name})); + const orgOptions = this.props.yourOrgs.map((item) => ({value: item.login, label: item.login})); + const repoOptions = this.props.yourReposByOrg.map((item) => ({value: item.full_name, label: item.name})); + + let orgSelector = null; + let helperTextForRepoSelector = 'Returns GitHub repositories connected to the user account'; + + // If there are no organizations for authenticated user, then don't show organization selector + if (orgOptions.length > 1) { + orgSelector = ( + <> + option.value === this.state.org)} + /> +
+ {'Returns GitHub organizations connected to the user account'} +
+ + ); + helperTextForRepoSelector = 'Returns GitHub repositories under selected organizations'; + } return (
+ {orgSelector} option.value === this.props.value)} />
- {'Returns GitHub repositories connected to the user account'}
+ {helperTextForRepoSelector}
); diff --git a/webapp/src/components/github_repo_selector/index.js b/webapp/src/components/github_repo_selector/index.js index 43616df85..8363b26d4 100644 --- a/webapp/src/components/github_repo_selector/index.js +++ b/webapp/src/components/github_repo_selector/index.js @@ -5,21 +5,27 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import manifest from 'manifest'; -import {getRepos} from '../../actions'; + +import {getReposByOrg, getOrgs} from '../../actions'; import GithubRepoSelector from './github_repo_selector.jsx'; function mapStateToProps(state) { return { - yourRepos: state[`plugins-${manifest.id}`].yourRepos, + yourOrgs: state[`plugins-${manifest.id}`].yourOrgs, + yourReposByOrg: state[`plugins-${manifest.id}`].yourReposByOrg, }; } function mapDispatchToProps(dispatch) { return { - actions: bindActionCreators({ - getRepos, - }, dispatch), + actions: bindActionCreators( + { + getOrgs, + getReposByOrg, + }, + dispatch, + ), }; } diff --git a/webapp/src/reducers/index.ts b/webapp/src/reducers/index.ts index 6dd440f27..d15810753 100644 --- a/webapp/src/reducers/index.ts +++ b/webapp/src/reducers/index.ts @@ -3,7 +3,7 @@ import {combineReducers} from 'redux'; -import {AttachCommentToIssueModalForPostIdData, ConfigurationData, ConnectedData, CreateIssueModalData, GithubUsersData, MentionsData, PrsDetailsData, ShowRhsPluginActionData, SidebarContentData, UserSettingsData, YourReposData} from '../types/github_types'; +import {AttachCommentToIssueModalForPostIdData, ConfigurationData, ConnectedData, CreateIssueModalData, GithubUsersData, MentionsData, PrsDetailsData, ShowRhsPluginActionData, SidebarContentData, UserSettingsData, YourReposData, Organization, RepositoriesByOrg} from '../types/github_types'; import ActionTypes from '../action_types'; import Constants from '../constants'; @@ -214,7 +214,27 @@ const attachCommentToIssueModalForPostId = (state = '', action: {type: string, d } }; +const yourOrgs = (state: Organization[] = [], action:{type:string, data: Organization[]}) => { + switch (action.type) { + case ActionTypes.RECEIVED_ORGANIZATIONS: + return action.data; + default: + return state; + } +}; + +const yourReposByOrg = (state: RepositoriesByOrg[] = [], action:{type: string, data: RepositoriesByOrg[]}) => { + switch (action.type) { + case ActionTypes.RECEIVED_REPOSITORIES_BY_ORGANIZATION: + return action.data; + default: + return state; + } +}; + export default combineReducers({ + yourOrgs, + yourReposByOrg, connected, enterpriseURL, organization, diff --git a/webapp/src/types/github_types.ts b/webapp/src/types/github_types.ts index 27aeb4941..7f044855a 100644 --- a/webapp/src/types/github_types.ts +++ b/webapp/src/types/github_types.ts @@ -139,3 +139,11 @@ export type SidebarData = { org: string, rhsState?: string | null } + +export type Organization = { + login: string; +} +export type RepositoriesByOrg = { + name: string; + fullName: string; +} \ No newline at end of file