github.com/driusan/bug@v0.3.2-0.20190306121946-d7f4e7f33fea/scm/GitManager.go (about)

     1  package scm
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"github.com/driusan/bug/bugs"
     7  	"io/ioutil"
     8  	"os"
     9  	"os/exec"
    10  	"regexp"
    11  	"strings"
    12  )
    13  
    14  type GitManager struct {
    15  	Autoclose    bool
    16  	UseBugPrefix bool
    17  }
    18  
    19  func (a GitManager) Purge(dir bugs.Directory) error {
    20  	cmd := exec.Command("git", "clean", "-fd", string(dir))
    21  
    22  	cmd.Stdin = os.Stdin
    23  	cmd.Stdout = os.Stdout
    24  	cmd.Stderr = os.Stderr
    25  	return cmd.Run()
    26  }
    27  
    28  type issueStatus struct {
    29  	a, d, m bool // Added, Deleted, Modified
    30  }
    31  type issuesStatus map[string]issueStatus
    32  
    33  // Get list of created, updated, closed and closed-on-github issues.
    34  //
    35  // In general following rules to categorize issues are applied:
    36  // * closed if Description file is deleted (D);
    37  // * created if Description file is created (A) (TODO: handle issue renamings);
    38  // * closed issue will also close issue on GH when Autoclose is true (see Identifier example);
    39  // * updated if Description file is modified (M);
    40  // * updated if Description is unchanged but any other files are touched. (' '+x)
    41  //
    42  // eg output from `from git status --porcelain`, appendix mine
    43  // note that `git add -A issues` was invoked before
    44  //
    45  // D  issues/First-GH-issue/Description		issue closed (GH issues are also here)
    46  // D  issues/First-GH-issue/Identifier		maybe it is GH issue, maybe not
    47  // M  issues/issue--2/Description		desc updated
    48  // A  issues/issue--2/Status			new field added (status); considered as update unless Description is also created
    49  // D  issues/issue1/Description			issue closed
    50  // A  issues/issue3/Description			new issue, description field is mandatory for rich format
    51  func (a GitManager) currentStatus(dir bugs.Directory) (closedOnGitHub []string, _ issuesStatus) {
    52  	ghRegex := regexp.MustCompile("(?im)^-Github:(.*)$")
    53  	closesGH := func(file string) (issue string, ok bool) {
    54  		if !a.Autoclose {
    55  			return "", false
    56  		}
    57  		if !strings.HasSuffix(file, "Identifier") {
    58  			return "", false
    59  		}
    60  		diff := exec.Command("git", "diff", "--staged", "--", file)
    61  		diffout, _ := diff.CombinedOutput()
    62  		matches := ghRegex.FindStringSubmatch(string(diffout))
    63  		if len(matches) > 1 {
    64  			return strings.TrimSpace(matches[1]), true
    65  		}
    66  		return "", false
    67  	}
    68  	short := func(path string) string {
    69  		b := strings.Index(path, "/")
    70  		e := strings.LastIndex(path, "/")
    71  		if b+1 >= e {
    72  			return "???"
    73  		}
    74  		return path[b+1 : e]
    75  	}
    76  
    77  	cmd := exec.Command("git", "status", "-z", "--porcelain", string(dir))
    78  	out, _ := cmd.CombinedOutput()
    79  	files := strings.Split(string(out), "\000")
    80  
    81  	issues := issuesStatus{}
    82  	var ghClosed []string
    83  	const minLineLen = 3 /*for path*/ + 2 /*for issues dir with path sep*/ + 3 /*for issue name, path sep and any file under issue dir*/
    84  	for _, file := range files {
    85  		if len(file) < minLineLen {
    86  			continue
    87  		}
    88  
    89  		path := file[3:]
    90  		op := file[0]
    91  		desc := strings.HasSuffix(path, "/Description")
    92  		name := short(path)
    93  		issue := issues[name]
    94  
    95  		switch {
    96  		case desc && op == 'D':
    97  			issue.d = true
    98  		case desc && op == 'A':
    99  			issue.a = true
   100  		default:
   101  			issue.m = true
   102  			if op == 'D' {
   103  				if ghIssue, ok := closesGH(path); ok {
   104  					ghClosed = append(ghClosed, ghIssue)
   105  					issue.d = true // to be sure
   106  				}
   107  			}
   108  		}
   109  
   110  		issues[name] = issue
   111  	}
   112  	return ghClosed, issues
   113  }
   114  
   115  // Create commit message by iterate over issues in order:
   116  // closed issues are most important (something is DONE, ok? ;), those issues will also become hidden)
   117  // new issues are next, with just updates at the end
   118  // TODO: do something if this message will be too long
   119  func (a GitManager) commitMsg(dir bugs.Directory) []byte {
   120  	ghClosed, issues := a.currentStatus(dir)
   121  
   122  	done, add, update, together := &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}
   123  	var cntd, cnta, cntu int
   124  
   125  	for issue, state := range issues {
   126  		if state.d {
   127  			fmt.Fprintf(done, ", %q", issue)
   128  			cntd++
   129  		} else if state.a {
   130  			fmt.Fprintf(add, ", %q", issue)
   131  			cnta++
   132  		} else if state.m {
   133  			fmt.Fprintf(update, ", %q", issue)
   134  			cntu++
   135  		}
   136  	}
   137  
   138  	f := func(b *bytes.Buffer, what string, many bool) {
   139  		if b.Len() == 0 {
   140  			return
   141  		}
   142  		var m string
   143  		if many {
   144  			m = "s:"
   145  		}
   146  		s := b.Bytes()[2:]
   147  		fmt.Fprintf(together, "%s issue%s %s; ", what, m, s)
   148  	}
   149  	f(done, "Close", cntd > 1)
   150  	f(add, "Create", cnta > 1)
   151  	f(update, "Update", cntu > 1)
   152  	if l := together.Len(); l > 0 {
   153  		together.Truncate(l - 2) // "; " from last applied f()
   154  	}
   155  
   156  	if len(ghClosed) > 0 {
   157  		fmt.Fprintf(together, "\n\nCloses %s\n", strings.Join(ghClosed, ", closes "))
   158  	}
   159  	return together.Bytes()
   160  }
   161  
   162  func (a GitManager) Commit(dir bugs.Directory, backupCommitMsg string) error {
   163  	cmd := exec.Command("git", "add", "-A", string(dir))
   164  	if err := cmd.Run(); err != nil {
   165  		fmt.Printf("Could not add issues to be commited: %s?\n", err.Error())
   166  		return err
   167  
   168  	}
   169  
   170  	msg := a.commitMsg(dir)
   171  
   172  	file, err := ioutil.TempFile("", "bugCommit")
   173  	if err != nil {
   174  		fmt.Fprintf(os.Stderr, "Could not create temporary file.\nNothing commited.\n")
   175  		return err
   176  	}
   177  	defer os.Remove(file.Name())
   178  
   179  	if len(msg) == 0 {
   180  		fmt.Fprintf(file, "%s\n", backupCommitMsg)
   181  	} else {
   182  		var pref string
   183  		if a.UseBugPrefix {
   184  			pref = "bug: "
   185  		}
   186  		fmt.Fprintf(file, "%s%s\n", pref, msg)
   187  	}
   188  	cmd = exec.Command("git", "commit", "-o", string(dir), "-F", file.Name(), "-q")
   189  	if err := cmd.Run(); err != nil {
   190  		// If nothing was added commit will have an error,
   191  		// but we don't care it just means there's nothing
   192  		// to commit.
   193  		fmt.Printf("No new issues commited\n")
   194  		return nil
   195  	}
   196  	return nil
   197  }
   198  
   199  func (a GitManager) GetSCMType() string {
   200  	return "git"
   201  }