Skip to content
62 changes: 56 additions & 6 deletions server/plugin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const (

requestTimeout = 30 * time.Second
oauthCompleteTimeout = 2 * time.Minute

channelIDParam = "channelId"
)

type OAuthState struct {
Expand Down Expand Up @@ -1224,11 +1226,32 @@ 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, int, error) {
repository, resp, err := githubClient.Repositories.Get(c, org, repo)
if err != nil {
return nil, resp.StatusCode, err
}

return repository, http.StatusOK, 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.writeAPIError(w, &APIErrorResponse{Message: "Bad request: missing channelId", StatusCode: http.StatusBadRequest})
return
}

defaultRepo, dErr := p.GetDefaultRepo(c.GHInfo.UserID, channelID)
if dErr != nil {
c.Log.Warnf("Failed to get the default repo for the channel")
}

var allRepos []*github.Repository
var err error
var statusCode int
Expand Down Expand Up @@ -1259,18 +1282,45 @@ func (p *Plugin) getRepositories(c *UserContext, w http.ResponseWriter, r *http.
}
}

// Only send down fields to client that are needed
type RepositoryResponse struct {
type RepoResponse struct {
Name string `json:"name,omitempty"`
FullName string `json:"full_name,omitempty"`
Permissions map[string]bool `json:"permissions,omitempty"`
}

resp := make([]RepositoryResponse, len(allRepos))
// Only send down fields to client that are needed
type RepositoryResponse struct {
DefaultRepo RepoResponse `json:"defaultRepo,omitempty"`
Repo []RepoResponse `json:"repo,omitempty"`
}

repoResp := make([]RepoResponse, len(allRepos))
for i, r := range allRepos {
resp[i].Name = r.GetName()
resp[i].FullName = r.GetFullName()
resp[i].Permissions = r.GetPermissions()
repoResp[i].Name = r.GetName()
repoResp[i].FullName = r.GetFullName()
repoResp[i].Permissions = r.GetPermissions()
}

resp := RepositoryResponse{
Repo: repoResp,
}

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.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)
Expand Down
119 changes: 117 additions & 2 deletions server/plugin/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,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,
Expand Down Expand Up @@ -702,6 +702,107 @@ 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 issue command. Available command is 'set', 'get' and 'unset'."
}

command := parameters[0]
parameters = parameters[1:]

switch {
case command == "set":
return p.handleSetDefaultRepo(c, args, parameters, userInfo)
case command == "get":
return p.handleGetDefaultRepo(c, args, parameters, userInfo)
case command == "unset":
return p.handleUnSetDefaultRepo(c, args, parameters, userInfo)
default:
return fmt.Sprintf("Unknown subcommand %v", command)
}
}

func (p *Plugin) handleSetDefaultRepo(_ *plugin.Context, 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 "invalid repository"
}

owner = strings.ToLower(owner)
repo = strings.ToLower(repo)

if config.GitHubOrg != "" && strings.ToLower(config.GitHubOrg) != owner {
return "repository is not part of the locked github organization"
}

ctx := context.Background()
githubClient := p.githubConnectUser(ctx, userInfo)

ghRepo, _, _ := githubClient.Repositories.Get(ctx, owner, repo)
if ghRepo == nil {
return fmt.Sprintf("unknown repository %s", fullNameFromOwnerAndRepo(owner, repo))
}

if _, err := p.store.Set(args.ChannelId+"_"+userInfo.UserID+"-default-repo", []byte(owner+"/"+repo)); err != nil {
return "error occurred saving the default repo"
}

repoLink := baseURL + owner + "/" + repo
successMsg := fmt.Sprintf("The default repo has been set to [%s/%s](%s)", owner, repo, repoLink)

return successMsg
}

func (p *Plugin) GetDefaultRepo(userID string, channelID string) (string, error) {
var defaultRepoBytes []byte
if err := p.store.Get(channelID+"_"+userID+"-default-repo", &defaultRepoBytes); err != nil {
return "", err
}

return string(defaultRepoBytes), nil
}

func (p *Plugin) handleGetDefaultRepo(_ *plugin.Context, args *model.CommandArgs, parameters []string, 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", "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(_ *plugin.Context, args *model.CommandArgs, parameters []string, 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", "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(args.ChannelId + "_" + userInfo.UserID + "-default-repo"); err != nil {
return "error occurred while unsetting the repo for this channel"
}

return "The default repository has been unset successfully"
}

func (p *Plugin) handleSetup(c *plugin.Context, args *model.CommandArgs, parameters []string) string {
userID := args.UserId
isSysAdmin, err := p.isAuthorizedSysAdmin(userID)
Expand Down Expand Up @@ -871,7 +972,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 {
Expand Down Expand Up @@ -944,6 +1045,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)

Expand Down
1 change: 1 addition & 0 deletions server/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ func NewPlugin() *Plugin {
"": p.handleHelp,
"settings": p.handleSettings,
"issue": p.handleIssue,
"default-repo": p.handleDefaultRepo,
}

p.createGithubEmojiMap()
Expand Down
6 changes: 5 additions & 1 deletion server/plugin/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,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}}
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) && this.props.yourRepos.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.repo.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.repo.map((item) => ({value: item.full_name, label: item.full_name}));

return (
<div className={'form-group margin-bottom x3'}>
Expand Down
3 changes: 3 additions & 0 deletions webapp/src/components/github_repo_selector/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';

import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';

import manifest from 'manifest';
import {getRepos} from '../../actions';

Expand All @@ -12,6 +14,7 @@ import GithubRepoSelector from './github_repo_selector.jsx';
function mapStateToProps(state) {
return {
yourRepos: state[`plugins-${manifest.id}`].yourRepos,
currentChannelId: getCurrentChannelId(state),
};
}

Expand Down
4 changes: 3 additions & 1 deletion webapp/src/reducers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ function sidebarContent(state = {
}
}

function yourRepos(state: YourReposData[] = [], action: {type: string, data: YourReposData[]}) {
function yourRepos(state: YourReposData = {
repo: [],
}, action: {type: string, data: YourReposData}) {
switch (action.type) {
case ActionTypes.RECEIVED_REPOSITORIES:
return action.data;
Expand Down
10 changes: 10 additions & 0 deletions webapp/src/types/github_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,17 @@ export type GithubIssueData = {
repository_url: string;
}

export type DefaultRepo = {
name: string;
full_name: string;
}

export type YourReposData = {
defaultRepo?: DefaultRepo;
repo: ReposData[];
}

export type ReposData = {
name: string;
full_name: string;
}
Expand Down