diff --git a/server/plugin/api.go b/server/plugin/api.go index cf9ff7269..49ffd8296 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -33,6 +33,8 @@ const ( requestTimeout = 30 * time.Second oauthCompleteTimeout = 2 * time.Minute + + channelIDParam = "channelId" ) type OAuthState struct { @@ -51,6 +53,18 @@ func (e *APIErrorResponse) Error() string { return e.Message } +type RepoResponse struct { + Name string `json:"name,omitempty"` + FullName string `json:"full_name,omitempty"` + Permissions map[string]bool `json:"permissions,omitempty"` +} + +// Only send down fields to client that are needed +type RepositoryResponse struct { + DefaultRepo RepoResponse `json:"defaultRepo,omitempty"` + Repos []RepoResponse `json:"repos,omitempty"` +} + type PRDetails struct { URL string `json:"url"` Number int `json:"number"` @@ -1257,10 +1271,26 @@ func getRepositoryListByOrg(c context.Context, org string, githubClient *github. return allRepos, http.StatusOK, nil } +func getRepository(c context.Context, org string, repo string, githubClient *github.Client) (*github.Repository, error) { + repository, _, err := githubClient.Repositories.Get(c, org, repo) + if err != nil { + return nil, err + } + + return repository, nil +} + func (p *Plugin) getRepositories(c *UserContext, w http.ResponseWriter, r *http.Request) { githubClient := p.githubConnectUser(c.Context.Ctx, c.GHInfo) org := p.getConfiguration().GitHubOrg + channelID := r.URL.Query().Get(channelIDParam) + if channelID == "" { + p.client.Log.Warn("Bad request: missing channelId") + p.writeAPIError(w, &APIErrorResponse{Message: "Bad request: missing channelId", StatusCode: http.StatusBadRequest}) + return + } + var allRepos []*github.Repository var err error @@ -1298,18 +1328,38 @@ func (p *Plugin) getRepositories(c *UserContext, w http.ResponseWriter, r *http. } } - // Only send down fields to client that are needed - type RepositoryResponse struct { - Name string `json:"name,omitempty"` - FullName string `json:"full_name,omitempty"` - Permissions map[string]bool `json:"permissions,omitempty"` + repoResp := make([]RepoResponse, len(allRepos)) + for i, r := range allRepos { + repoResp[i].Name = r.GetName() + repoResp[i].FullName = r.GetFullName() + repoResp[i].Permissions = r.GetPermissions() } - resp := make([]RepositoryResponse, len(allRepos)) - for i, r := range allRepos { - resp[i].Name = r.GetName() - resp[i].FullName = r.GetFullName() - resp[i].Permissions = r.GetPermissions() + resp := RepositoryResponse{ + Repos: repoResp, + } + + defaultRepo, dErr := p.GetDefaultRepo(c.GHInfo.UserID, channelID) + if dErr != nil { + c.Log.WithError(dErr).Warnf("Failed to get the default repo for the channel. UserID: %s. ChannelID: %s", c.GHInfo.UserID, channelID) + } + + if defaultRepo != "" { + config := p.getConfiguration() + baseURL := config.getBaseURL() + owner, repo := parseOwnerAndRepo(defaultRepo, baseURL) + defaultRepository, err := getRepository(c.Ctx, owner, repo, githubClient) + if err != nil { + c.Log.WithError(err).Warnf("Failed to get the default repo %s/%s", owner, repo) + } + + if defaultRepository != nil { + resp.DefaultRepo = RepoResponse{ + Name: *defaultRepository.Name, + FullName: *defaultRepository.FullName, + Permissions: defaultRepository.Permissions, + } + } } p.writeJSON(w, resp) diff --git a/server/plugin/command.go b/server/plugin/command.go index fe7aff6d4..cda00bee7 100644 --- a/server/plugin/command.go +++ b/server/plugin/command.go @@ -37,6 +37,8 @@ const ( PerPageValue = 50 ) +const DefaultRepoKey string = "%s_%s-default-repo" + var validFeatures = map[string]bool{ featureIssueCreation: true, featureIssues: true, @@ -123,7 +125,7 @@ func (p *Plugin) getCommand(config *Configuration) (*model.Command, error) { return &model.Command{ Trigger: "github", AutoComplete: true, - AutoCompleteDesc: "Available commands: connect, disconnect, todo, subscriptions, issue, me, mute, settings, help, about", + AutoCompleteDesc: "Available commands: connect, disconnect, todo, subscriptions, issue, default-repo, me, mute, settings, help, about", AutoCompleteHint: "[command]", AutocompleteData: getAutocompleteData(config), AutocompleteIconData: iconData, @@ -713,6 +715,110 @@ func (p *Plugin) handleIssue(_ *plugin.Context, args *model.CommandArgs, paramet } } +func (p *Plugin) handleDefaultRepo(c *plugin.Context, args *model.CommandArgs, parameters []string, userInfo *GitHubUserInfo) string { + if len(parameters) == 0 { + return "Invalid action. Available actions are 'set', 'get' and 'unset'." + } + + command := parameters[0] + parameters = parameters[1:] + + switch { + case command == "set": + return p.handleSetDefaultRepo(args, parameters, userInfo) + case command == "get": + return p.handleGetDefaultRepo(args, userInfo) + case command == "unset": + return p.handleUnSetDefaultRepo(args, userInfo) + default: + return fmt.Sprintf("Unknown subcommand %v", command) + } +} + +func (p *Plugin) handleSetDefaultRepo(args *model.CommandArgs, parameters []string, userInfo *GitHubUserInfo) string { + if len(parameters) == 0 { + return "Please specify a repository." + } + + repo := parameters[0] + config := p.getConfiguration() + baseURL := config.getBaseURL() + owner, repo := parseOwnerAndRepo(repo, baseURL) + if owner == "" || repo == "" { + return "Please provide a valid repository" + } + + owner = strings.ToLower(owner) + repo = strings.ToLower(repo) + + if config.GitHubOrg != "" && strings.ToLower(config.GitHubOrg) != owner { + return fmt.Sprintf("Repository is not part of the locked Github organization. Locked Github organization: %s", config.GitHubOrg) + } + + ctx := context.Background() + githubClient := p.githubConnectUser(ctx, userInfo) + + ghRepo, _, err := githubClient.Repositories.Get(ctx, owner, repo) + if err != nil { + return "Error occurred while getting github repository details" + } + if ghRepo == nil { + return fmt.Sprintf("Unknown repository %s", fullNameFromOwnerAndRepo(owner, repo)) + } + + if _, err := p.store.Set(fmt.Sprintf(DefaultRepoKey, args.ChannelId, userInfo.UserID), []byte(fmt.Sprintf("%s/%s", owner, repo))); err != nil { + return "Error occurred saving the default repo" + } + + repoLink := fmt.Sprintf("%s%s/%s", baseURL, owner, repo) + successMsg := fmt.Sprintf("The default repo has been set to [%s/%s](%s) for this channel", owner, repo, repoLink) + + return successMsg +} + +func (p *Plugin) GetDefaultRepo(userID, channelID string) (string, error) { + var defaultRepoBytes []byte + if err := p.store.Get(fmt.Sprintf(DefaultRepoKey, channelID, userID), &defaultRepoBytes); err != nil { + return "", err + } + + return string(defaultRepoBytes), nil +} + +func (p *Plugin) handleGetDefaultRepo(args *model.CommandArgs, userInfo *GitHubUserInfo) string { + defaultRepo, err := p.GetDefaultRepo(userInfo.UserID, args.ChannelId) + if err != nil { + p.client.Log.Warn("Not able to get the default repo", "UserID", userInfo.UserID, "ChannelID", args.ChannelId, "Error", err.Error()) + return "Error occurred while getting the default repo" + } + + if defaultRepo == "" { + return "You have not set a default repository for this channel" + } + + config := p.getConfiguration() + repoLink := config.getBaseURL() + defaultRepo + return fmt.Sprintf("The default repository is [%s](%s)", defaultRepo, repoLink) +} + +func (p *Plugin) handleUnSetDefaultRepo(args *model.CommandArgs, userInfo *GitHubUserInfo) string { + defaultRepo, err := p.GetDefaultRepo(userInfo.UserID, args.ChannelId) + if err != nil { + p.client.Log.Warn("Not able to get the default repo", "UserID", userInfo.UserID, "ChannelID", args.ChannelId, "Error", err.Error()) + return "Error occurred while getting the default repo" + } + + if defaultRepo == "" { + return "You have not set a default repository for this channel" + } + + if err := p.store.Delete(fmt.Sprintf(DefaultRepoKey, args.ChannelId, userInfo.UserID)); err != nil { + return "Error occurred while unsetting the repo for this channel" + } + + return "The default repository has been unset successfully" +} + func (p *Plugin) handleSetup(_ *plugin.Context, args *model.CommandArgs, parameters []string) string { userID := args.UserId isSysAdmin, err := p.isAuthorizedSysAdmin(userID) @@ -882,7 +988,7 @@ func getAutocompleteData(config *Configuration) *model.AutocompleteData { return github } - github := model.NewAutocompleteData("github", "[command]", "Available commands: connect, disconnect, todo, subscriptions, issue, me, mute, settings, help, about") + github := model.NewAutocompleteData("github", "[command]", "Available commands: connect, disconnect, todo, subscriptions, issue, default-repo, me, mute, settings, help, about") connect := model.NewAutocompleteData("connect", "", "Connect your Mattermost account to your GitHub account") if config.EnablePrivateRepo { @@ -955,6 +1061,20 @@ func getAutocompleteData(config *Configuration) *model.AutocompleteData { github.AddCommand(issue) + defaultRepo := model.NewAutocompleteData("default-repo", "[command]", "Available commands: set, get, unset") + defaultRepoSet := model.NewAutocompleteData("set", "[owner/repo]", "Set the default repository for the channel") + defaultRepoSet.AddTextArgument("Owner/repo to set as a default repository", "[owner/repo]", "") + + defaultRepoGet := model.NewAutocompleteData("get", "", "Get the default repository already set for the channel") + + defaultRepoDelete := model.NewAutocompleteData("unset", "", "Unset the default repository set for the channel") + + defaultRepo.AddCommand(defaultRepoSet) + defaultRepo.AddCommand(defaultRepoGet) + defaultRepo.AddCommand(defaultRepoDelete) + + github.AddCommand(defaultRepo) + me := model.NewAutocompleteData("me", "", "Display the connected GitHub account") github.AddCommand(me) diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 0a34bec1d..deab7c053 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -124,6 +124,7 @@ func NewPlugin() *Plugin { "": p.handleHelp, "settings": p.handleSettings, "issue": p.handleIssue, + "default-repo": p.handleDefaultRepo, } p.createGithubEmojiMap() diff --git a/server/plugin/template.go b/server/plugin/template.go index 24895220f..2aaa1e3a1 100644 --- a/server/plugin/template.go +++ b/server/plugin/template.go @@ -451,7 +451,11 @@ Assignees: {{range $i, $el := .Assignees -}} {{- if $i}}, {{end}}{{template "use " * `/github mute list` - list your muted GitHub users\n" + " * `/github mute add [username]` - add a GitHub user to your muted list\n" + " * `/github mute delete [username]` - remove a GitHub user from your muted list\n" + - " * `/github mute delete-all` - unmute all GitHub users\n")) + " * `/github mute delete-all` - unmute all GitHub users\n" + + "* `/github default-repo` - Manage the default repository per channel for the user. The default repository will be auto selected for creating the issues\n" + + " * `/github default-repo set owner[/repo]` - set the default repo for the channel\n" + + " * `/github default-repo get` - get the default repo for the channel\n" + + " * `/github default-repo unset` - unset the default repo for the channel\n")) template.Must(masterTemplate.New("newRepoStar").Funcs(funcMap).Parse(` {{template "repo" .GetRepo}} diff --git a/webapp/src/actions/index.ts b/webapp/src/actions/index.ts index af1f7ee22..bd050a2d9 100644 --- a/webapp/src/actions/index.ts +++ b/webapp/src/actions/index.ts @@ -70,11 +70,11 @@ export function getReviewsDetails(prList: PrsDetailsData[]) { }; } -export function getRepos() { +export function getRepos(channelId: string) { return async (dispatch: DispatchFunc) => { let data; try { - data = await Client.getRepositories(); + data = await Client.getRepositories(channelId); } catch (error) { dispatch({ type: ActionTypes.RECEIVED_REPOSITORIES, diff --git a/webapp/src/client/client.js b/webapp/src/client/client.js index e381dbf5d..82d472dbe 100644 --- a/webapp/src/client/client.js +++ b/webapp/src/client/client.js @@ -31,8 +31,8 @@ export default class Client { return this.doPost(`${this.url}/user`, {user_id: userID}); } - getRepositories = async () => { - return this.doGet(`${this.url}/repositories`); + getRepositories = async (channelId) => { + return this.doGet(`${this.url}/repositories?channelId=${channelId}`); } getLabels = async (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 d4069b3e5..f6b72a8b6 100644 --- a/webapp/src/components/github_repo_selector/github_repo_selector.jsx +++ b/webapp/src/components/github_repo_selector/github_repo_selector.jsx @@ -13,10 +13,11 @@ const initialState = { export default class GithubRepoSelector extends PureComponent { static propTypes = { - yourRepos: PropTypes.array.isRequired, + yourRepos: PropTypes.object.isRequired, theme: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, value: PropTypes.string, + currentChannelId: PropTypes.string, addValidate: PropTypes.func, removeValidate: PropTypes.func, actions: PropTypes.shape({ @@ -30,16 +31,24 @@ export default class GithubRepoSelector extends PureComponent { } componentDidMount() { - this.props.actions.getRepos(); + this.props.actions.getRepos(this.props.currentChannelId); + } + + componentDidUpdate() { + const defaultRepo = this.props.yourRepos.defaultRepo; + + if (!(this.props.value) && defaultRepo?.full_name) { + this.onChange(defaultRepo.name, defaultRepo.full_name); + } } onChange = (_, name) => { - const repo = this.props.yourRepos.find((r) => r.full_name === name); + const repo = this.props.yourRepos.repos.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 repoOptions = this.props.yourRepos.repos.map((item) => ({value: item.full_name, label: item.full_name})); return (