Skip to content

Commit

Permalink
[MM-611]: Added the feature to select the default repository for the …
Browse files Browse the repository at this point in the history
…channel (#806)

* [MM-611]: Added the feature to select the default repository for the channel per User

* [MM-611]: Updated the help text

* [MM-611]: review fixes

* [MM-611]: review fixes

* [MM-611]: review fixes

* [MM-611]: Fixed the lint issue

* [MM-611]: resolved map accessing empty array error

* [MM-611]: review fixes

* updated json for repo data
  • Loading branch information
Kshitij-Katiyar authored Jan 29, 2025
1 parent 6f5c7bf commit 29da42c
Show file tree
Hide file tree
Showing 10 changed files with 221 additions and 22 deletions.
70 changes: 60 additions & 10 deletions server/plugin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const (

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

channelIDParam = "channelId"
)

type OAuthState struct {
Expand All @@ -54,6 +56,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"`
Expand Down Expand Up @@ -1354,10 +1368,26 @@ func (p *Plugin) getRepositoryListByOrg(c context.Context, ghInfo *GitHubUserInf
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

Expand Down Expand Up @@ -1395,18 +1425,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)
Expand Down
124 changes: 122 additions & 2 deletions server/plugin/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const (
PerPageValue = 50
)

const DefaultRepoKey string = "%s_%s-default-repo"

var validFeatures = map[string]bool{
featureIssueCreation: true,
featureIssues: true,
Expand Down Expand Up @@ -127,7 +129,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 @@ -743,6 +745,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)
Expand Down Expand Up @@ -912,7 +1018,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 @@ -985,6 +1091,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 @@ -129,6 +129,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 @@ -461,7 +461,11 @@ Reviewers: {{range $i, $el := .RequestedReviewers -}} {{- if $i}}, {{end}}{{temp
" * `/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) && 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 (
<div className={'form-group 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 @@ -13,6 +15,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 = {
repos: [],
}, 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 @@ -89,7 +89,17 @@ export type GithubIssueData = {
repository_url: string;
}

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

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

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

0 comments on commit 29da42c

Please sign in to comment.