diff --git a/.gitignore b/.gitignore index ff3c350..a7dd4a0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ _testmain.go *.test *.prof *.un~ + +coverage.txt +profile.out diff --git a/.travis.yml b/.travis.yml index 9f0068e..dd5e501 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,10 @@ language: go -sudo: false +sudo: required +dist: trusty + # Trusty Tahr seems to be the only way to get + # a modern version of git on Travis. Once they've + # upgraded with non-beta, this can be removed along + # with sudo: required.. os: - linux - osx @@ -8,10 +13,11 @@ go: addons: apt: packages: - - git-2.0 + - git before_install: + - git config -l - git --version - hg --version diff --git a/bug-import/be.go b/bug-import/be.go index 69f88c2..c673c8e 100644 --- a/bug-import/be.go +++ b/bug-import/be.go @@ -79,7 +79,7 @@ func beImportBug(identifier, issuesDir, fullbepath string) { bugdir := bugs.TitleToDir(beBug.Summary) - b := bugs.Bug{bugs.Directory(issuesDir) + bugdir} + b := bugs.Bug{Dir: bugs.Directory(issuesDir) + bugdir} if dir := b.GetDirectory(); dir != "" { os.Mkdir(string(dir), 0755) } diff --git a/bug-import/github.go b/bug-import/github.go index a133f48..161abf8 100644 --- a/bug-import/github.go +++ b/bug-import/github.go @@ -22,7 +22,7 @@ func githubImport(user, repo string) { for lastPage := false; lastPage != true; { for _, issue := range issues { if issue.PullRequestLinks == nil { - b := bugs.Bug{issueDir + bugs.TitleToDir(*issue.Title)} + b := bugs.Bug{Dir: issueDir + bugs.TitleToDir(*issue.Title)} if dir := b.GetDirectory(); dir != "" { os.Mkdir(string(dir), 0755) } diff --git a/bug-serve/Makefile b/bug-serve/Makefile deleted file mode 100644 index f5fc7b3..0000000 --- a/bug-serve/Makefile +++ /dev/null @@ -1,14 +0,0 @@ -all: Go React - -Go: - go build . - -js: React - -jsx: React - -React: - babel --out-dir=js jsx - -serve: - ./bug-serve diff --git a/bug-serve/README.md b/bug-serve/README.md deleted file mode 100644 index 8284fb5..0000000 --- a/bug-serve/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# bug-serve - -This directory contains an application to serve a issues/ -directory managed by bug over HTTP using React.JS and -Bootstrap. - -It's not as mature as the bug code. diff --git a/bug-serve/issues/Add-ability-to-edit-bugs/Description b/bug-serve/issues/Add-ability-to-edit-bugs/Description deleted file mode 100644 index e850106..0000000 --- a/bug-serve/issues/Add-ability-to-edit-bugs/Description +++ /dev/null @@ -1 +0,0 @@ -You should be able to edit bugs via AJAX in bug-serve diff --git a/bug-serve/issues/Include-directory-being-served-in-title/Description b/bug-serve/issues/Include-directory-being-served-in-title/Description deleted file mode 100644 index e8709d1..0000000 --- a/bug-serve/issues/Include-directory-being-served-in-title/Description +++ /dev/null @@ -1 +0,0 @@ -The title in bug-serve include the directory being served diff --git a/bug-serve/issues/Should-titles-instead-of-directories/Description b/bug-serve/issues/Should-titles-instead-of-directories/Description deleted file mode 100644 index 64e1f1b..0000000 --- a/bug-serve/issues/Should-titles-instead-of-directories/Description +++ /dev/null @@ -1,2 +0,0 @@ -The front end should show the pretty title, not the directory name -of any issues diff --git a/bug-serve/jsx/BugApp.js b/bug-serve/jsx/BugApp.js deleted file mode 100644 index 6483f20..0000000 --- a/bug-serve/jsx/BugApp.js +++ /dev/null @@ -1,60 +0,0 @@ -var BugApp = React.createClass({ - - componentDidMount: function() { - var that = this; - AjaxGet("/issues/", function(response) { - that.setState({ - "Bugs" : JSON.parse(response) - }); - }); - AjaxGet("/settings", function(response) { - that.setState({ - "Settings" : JSON.parse(response) - }); - }) - }, - getInitialState : function() { - return { - "Settings" : {}, - "Title" : "Open Issues", - "Bugs": [], - "SelectedBugJSON" : null - } - }, - selectBugHandler: function(e) { - e.preventDefault(); - var bug = e.currentTarget.textContent; - var that = this; - AjaxGet("/issues/" + bug + "?format=json", function(response) { - that.setState({SelectedBug : JSON.parse(response)}); - }); - }, - resetSelected: function() { - this.setState({ "SelectedBug" : null}); - }, - render: function() { - var content; - if(this.state.SelectedBug != null) { - content = - } else { - content = - } - return (
-

Issues for: {this.state.Settings.Title}

-
- {content} -
-
); - } -}); - -var AjaxGet = function(url, callback) { - var xmlhttp = new XMLHttpRequest(); - xmlhttp.onreadystatechange = function() { - if (this.readyState === 4 && this.status == 200) { - callback(this.responseText) - } - } - xmlhttp.open("GET", url, true); - xmlhttp.send() -} diff --git a/bug-serve/jsx/BugList.js b/bug-serve/jsx/BugList.js deleted file mode 100644 index bdd24f4..0000000 --- a/bug-serve/jsx/BugList.js +++ /dev/null @@ -1,37 +0,0 @@ -var BugList = React.createClass({ - getDefaultProps: function() { - return { - "Title" : "Bugs", - "Bugs" : [], - onBugClicked: function(e) { e.preventDefault(); return } - } - }, - render: function() { - var that = this; - var elements = this.props.Bugs.map(function (val) { - return (
  • - {val} -
  • ); - }); - return (
    -

    {this.props.Title}

    -
      - {elements} -
    -
    - ); - } -}); -/* -func (b BugListRenderer) GetBody() string { - issues, _ := ioutil.ReadDir(bugs.GetRootDir() + "/issues") - - ret := "

    " + b.Title + "

      " - for _, issue := range issues { - var dir bugs.Directory = bugs.Directory(issue.Name()) - ret += fmt.Sprintf("
    1. %s
    2. \n", (dir), dir.ToTitle()) - } - ret += "
    " - - return ret -}*/ diff --git a/bug-serve/jsx/BugPage.js b/bug-serve/jsx/BugPage.js deleted file mode 100644 index eb368fe..0000000 --- a/bug-serve/jsx/BugPage.js +++ /dev/null @@ -1,18 +0,0 @@ -var BugPage = React.createClass({ - render: function() { - return ( -
    -
    -

    {this.props.Title}

    -
    {this.props.Description}
    - Return to list -
    -
    - -
    -
    ); - } -}); diff --git a/bug-serve/main.go b/bug-serve/main.go deleted file mode 100644 index 34b9af2..0000000 --- a/bug-serve/main.go +++ /dev/null @@ -1,117 +0,0 @@ -package main - -import ( - "encoding/json" - // "fmt" - "github.com/driusan/GoWebapp/HTMLPageRenderer" - "github.com/driusan/GoWebapp/URLHandler" - "github.com/driusan/bug/bugs" - "io/ioutil" - "net/http" -) - -type MainPageHandler struct { - URLHandler.DefaultHandler -} - -type BugPageHandler struct { - URLHandler.DefaultHandler -} - -type SettingsHandler struct { - URLHandler.DefaultHandler -} -type BugListRenderer struct { - HTMLPageRenderer.ReactPage -} - -type BugRenderer struct { - HTMLPageRenderer.ReactPage - Bug bugs.Bug -} - -func (s SettingsHandler) Get(r *http.Request, p map[string]interface{}) (string, error) { - settings := struct { - Title string - Directory string - }{bugs.GetRootDir().GetShortName().ToTitle(), string(bugs.GetRootDir())} - retVal, _ := json.Marshal(settings) - return string(retVal), nil -} -func (m MainPageHandler) Get(r *http.Request, p map[string]interface{}) (string, error) { - page := BugListRenderer{} - page.Title = "Open Issues" - page.JSFiles = []string{ - // Bootstrap - //"https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js", - // React - "https://cdnjs.cloudflare.com/ajax/libs/react/0.14.3/react.js", - "https://cdnjs.cloudflare.com/ajax/libs/react/0.14.3/react-dom.js", - "/js/BugApp.js", - "/js/BugList.js", - "/js/BugPage.js", - } - page.CSSFiles = []string{ - // Bootstrap - "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css", - "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css", - } - page.RootElement = "BugApp" - - return HTMLPageRenderer.Render(page), nil -} - -func getBugList() (string, error) { - issues, _ := ioutil.ReadDir(string(bugs.GetRootDir()) + "/issues") - - var issuesSlice []string - - for _, issue := range issues { - issuesSlice = append(issuesSlice, issue.Name()) - } - - retVal, _ := json.Marshal(issuesSlice) - return string(retVal), nil -} -func (m BugPageHandler) Get(r *http.Request, extras map[string]interface{}) (string, error) { - if r.URL.Path == "/issues" || r.URL.Path == "/issues/" { - return getBugList() - } - bugDir := string(bugs.GetRootDir()) + r.URL.Path - b := bugs.Bug{} - b.LoadBug(bugs.Directory(bugDir)) - - switch r.URL.Query().Get("format") { - case "json": - bJSON, _ := json.Marshal(b) - return string(bJSON), nil - default: - page := BugRenderer{Bug: b} - page.RootElement = "RBugPage" - page.Title = b.Title("") - page.JSFiles = []string{ - // Bootstrap JS - //"https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js", - // React JS - "https://cdnjs.cloudflare.com/ajax/libs/react/0.14.3/react.js", - "https://cdnjs.cloudflare.com/ajax/libs/react/0.14.3/react-dom.js", - "/js/BugPage.js", - } - page.CSSFiles = []string{ - "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css", - "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css"} - return HTMLPageRenderer.Render(page), nil - } - -} - -func main() { - URLHandler.RegisterHandler(MainPageHandler{}, "/") - URLHandler.RegisterHandler(SettingsHandler{}, "/settings") - URLHandler.RegisterHandler(BugPageHandler{}, "/issues/") - URLHandler.RegisterStaticHandler("/js/", "./js") - http.ListenAndServe(":8080", nil) -} - -//go:generate FileConstGenerator main js/BugApp.js BugListConst.go BugListJS -//go:generate FileConstGenerator main js/BugApp.js BugListConst.go BugListJS diff --git a/bugapp/Close.go b/bugapp/Close.go index 54a55c7..c64d36b 100644 --- a/bugapp/Close.go +++ b/bugapp/Close.go @@ -15,13 +15,17 @@ func Close(args ArgumentList) { // There were parameters, so show the full description of each // of those issues + var bugsToClose []string for _, bugID := range args { if bug, err := bugs.LoadBugByHeuristic(bugID); err == nil { dir := bug.GetDirectory() - fmt.Printf("Removing %s\n", dir) - os.RemoveAll(string(dir)) + bugsToClose = append(bugsToClose, string(dir)) } else { - fmt.Printf("Could not close bug %s: %s\n", bugID, err) + fmt.Fprintf(os.Stderr, "Could not close bug %s: %s\n", bugID, err) } } + for _, dir := range bugsToClose { + fmt.Printf("Removing %s\n", dir) + os.RemoveAll(dir) + } } diff --git a/bugapp/Close_test.go b/bugapp/Close_test.go index 38e7b02..590b287 100644 --- a/bugapp/Close_test.go +++ b/bugapp/Close_test.go @@ -120,3 +120,80 @@ func TestCloseBugByIdentifier(t *testing.T) { t.Error("Unexpected number of issues in issues dir\n") } } + +func TestCloseMultipleIndexesWithLastIndex(t *testing.T) { + dir, err := ioutil.TempDir("", "closetest") + defer os.RemoveAll(dir) + if err != nil { + t.Error("Could not create temporary dir for test") + return + } + os.Chdir(dir) + os.Setenv("PMIT", dir) + os.MkdirAll("issues/Test", 0700) + os.MkdirAll("issues/Test2", 0700) + os.MkdirAll("issues/Test3", 0700) + issuesDir, err := ioutil.ReadDir(fmt.Sprintf("%s/issues/", dir)) + if err != nil { + t.Error("Could not read issues directory") + return + } + if len(issuesDir) != 3 { + t.Error("Unexpected number of issues in issues dir after creating multiple issues\n") + } + _, stderr := captureOutput(func() { + Close(ArgumentList{"1", "3"}) + }, t) + issuesDir, err = ioutil.ReadDir(fmt.Sprintf("%s/issues/", dir)) + if err != nil { + t.Error("Could not read issues directory") + return + } + // After closing, there should be 1 bug. Otherwise, it probably + // means that the last error was "invalid index" since indexes + // were renumbered after closing the first bug. + if len(issuesDir) != 1 { + fmt.Printf("%s\n\n", stderr) + t.Error("Unexpected number of issues in issues dir after closing multiple issues\n") + } +} + +func TestCloseMultipleIndexesAtOnce(t *testing.T) { + dir, err := ioutil.TempDir("", "closetest") + defer os.RemoveAll(dir) + if err != nil { + t.Error("Could not create temporary dir for test") + return + } + os.Chdir(dir) + os.Setenv("PMIT", dir) + os.MkdirAll("issues/Test", 0700) + os.MkdirAll("issues/Test2", 0700) + os.MkdirAll("issues/Test3", 0700) + issuesDir, err := ioutil.ReadDir(fmt.Sprintf("%s/issues/", dir)) + if err != nil { + t.Error("Could not read issues directory") + return + } + if len(issuesDir) != 3 { + t.Error("Unexpected number of issues in issues dir after creating multiple issues\n") + } + _, _ = captureOutput(func() { + Close(ArgumentList{"1", "2"}) + }, t) + issuesDir, err = ioutil.ReadDir(fmt.Sprintf("%s/issues/", dir)) + if err != nil { + t.Error("Could not read issues directory") + return + } + if len(issuesDir) != 1 { + t.Error("Unexpected number of issues in issues dir after closing multiple issues\n") + return + } + + // 1 and 2 should have closed. If 3 was renumbered after 1 was closed, + // it would be closed instead. + if issuesDir[0].Name() != "Test3" { + t.Error("Closed incorrect issue when closing multiple issues.") + } +} diff --git a/bugapp/Commit.go b/bugapp/Commit.go index 2d8944d..1a917e1 100644 --- a/bugapp/Commit.go +++ b/bugapp/Commit.go @@ -13,6 +13,7 @@ func Commit(args ArgumentList) { } else { options["autoclose"] = false } + options["use_bug_prefix"] = true // SCM will ignore this option if it doesn't know it scm, _, err := scm.DetectSCM(options) if err != nil { diff --git a/bugapp/Create.go b/bugapp/Create.go index 027cc85..cc5fafe 100644 --- a/bugapp/Create.go +++ b/bugapp/Create.go @@ -45,6 +45,13 @@ func Create(Args ArgumentList) { identifier = generateID(strings.Join(Args, " ")) } + // It's possible there were arguments provided, but still no title + // included. Do another check before trying to create the bug. + if strings.TrimSpace(strings.Join(Args, " ")) == "" { + fmt.Fprintf(os.Stderr, "Usage: %s create [-n] Bug Description\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\nNo Bug Description provided.\n") + return + } var bug bugs.Bug bug = bugs.Bug{ Dir: bugs.GetIssuesDir() + bugs.TitleToDir(strings.Join(Args, " ")), diff --git a/bugapp/Help.go b/bugapp/Help.go index 4fa7192..3055559 100644 --- a/bugapp/Help.go +++ b/bugapp/Help.go @@ -35,6 +35,7 @@ time as creating it. Valid options are: --priority Sets the priority to the next parameter --milestone Sets the milestone to the next parameter --identifier Sets the identifier to the next parameter + --generate-id Automatically generate a stable bug identifier `, os.Args[0]) case "list": fmt.Printf("Usage: " + os.Args[0] + " list [BugIDs]\n") diff --git a/bugapp/List.go b/bugapp/List.go index 49e040d..b43806a 100644 --- a/bugapp/List.go +++ b/bugapp/List.go @@ -26,7 +26,7 @@ func listTags(files []os.FileInfo, args ArgumentList) { } } } -func List(args ArgumentList, stdout *os.File) { +func List(args ArgumentList) { issues, _ := ioutil.ReadDir(string(bugs.GetIssuesDir())) var wantTags bool = false @@ -42,7 +42,7 @@ func List(args ArgumentList, stdout *os.File) { continue } var dir bugs.Directory = bugs.GetIssuesDir() + bugs.Directory(issue.Name()) - b := bugs.Bug{dir} + b := bugs.Bug{Dir: dir} name := getBugName(b, idx) if wantTags == false { fmt.Printf("%s: %s\n", name, b.Title("")) diff --git a/bugapp/Version.go b/bugapp/Version.go index c40dd30..39587e8 100644 --- a/bugapp/Version.go +++ b/bugapp/Version.go @@ -6,6 +6,6 @@ import ( ) func Version() { - fmt.Printf("%s version 0.3.1\n", os.Args[0]) + fmt.Printf("%s version 0.4\n", os.Args[0]) } diff --git a/bugs/Bug.go b/bugs/Bug.go index f2ffc88..b300a8c 100644 --- a/bugs/Bug.go +++ b/bugs/Bug.go @@ -1,6 +1,7 @@ package bugs import ( + "errors" "fmt" "io/ioutil" "os" @@ -8,8 +9,12 @@ import ( "strings" ) +var NoDescriptionError = errors.New("No description provided") +var NotFoundError = errors.New("Could not find bug") + type Bug struct { - Dir Directory + Dir Directory + descFile *os.File } type Tag string @@ -53,24 +58,24 @@ func (b *Bug) LoadBug(dir Directory) { } func (b Bug) Title(options string) string { - var checkOption = func(o string) bool { + var hasOption = func(o string) bool { return strings.Contains(options, o) } title := b.Dir.GetShortName().ToTitle() - if id := b.Identifier(); checkOption("identifier") && id != "" { + if id := b.Identifier(); hasOption("identifier") && id != "" { title = fmt.Sprintf("(%s) %s", id, title) } - if strings.Contains(options, "tags") { + if hasOption("tags") { tags := b.StringTags() if len(tags) > 0 { title += fmt.Sprintf(" (%s)", strings.Join(tags, ", ")) } } - priority := checkOption("priority") && b.Priority() != "" - status := checkOption("status") && b.Status() != "" + priority := hasOption("priority") && b.Priority() != "" + status := hasOption("status") && b.Status() != "" if options == "" { priority = false status = false @@ -85,15 +90,21 @@ func (b Bug) Title(options string) string { } return title } + func (b Bug) Description() string { - dir := b.GetDirectory() - desc, err := ioutil.ReadFile(string(dir) + "/Description") + value, err := ioutil.ReadAll(&b) if err != nil { - return "No description provided" + if err == NoDescriptionError { + return "No description provided." + } + panic("Unhandled error" + err.Error()) } - return string(desc) + if string(value) == "" { + return "No description provided." + } + return string(value) } func (b Bug) SetDescription(val string) error { dir := b.GetDirectory() @@ -244,3 +255,12 @@ func (b Bug) Identifier() string { func (b Bug) SetIdentifier(newValue string) error { return b.setField("Identifier", newValue) } + +func New(title string) (*Bug, error) { + expectedDir := GetIssuesDir() + TitleToDir(title) + err := os.Mkdir(string(expectedDir), 0755) + if err != nil { + return nil, err + } + return &Bug{Dir: expectedDir}, nil +} diff --git a/bugs/Bug_test.go b/bugs/Bug_test.go index 76edfbb..d704e56 100644 --- a/bugs/Bug_test.go +++ b/bugs/Bug_test.go @@ -2,24 +2,39 @@ package bugs import ( "fmt" + "io/ioutil" + "os" "testing" ) -func TestDirectoryToTitle(t *testing.T) { - var assertTitle = func(directory, title string) { - dir := Directory(directory) - if dir.ToTitle() != title { - t.Error("Failed on " + directory + ": got " + dir.ToTitle() + " but expected " + title) - } - } - assertTitle("Test", "Test") - assertTitle("Test-Multiword", "Test Multiword") - assertTitle("Test--Dash", "Test-Dash") - assertTitle("Test---Dash", "Test--Dash") - assertTitle("Test_--TripleDash", "Test --TripleDash") - assertTitle("Test_-_What", "Test - What") +type tester struct { + dir string + bug *Bug } +func (t *tester) Setup() { + gdir, err := ioutil.TempDir("", "issuetest") + if err == nil { + os.Chdir(gdir) + t.dir = gdir + os.Unsetenv("PMIT") + // Hack to get around the fact that /tmp is a symlink on + // OS X, and it causes the directory checks to fail.. + gdir, _ = os.Getwd() + } else { + panic("Failed creating temporary directory") + } + // Make sure we get the right directory from the top level + os.Mkdir("issues", 0755) + b, err := New("Test Bug") + if err != nil { + panic("Unexpected error creating Test Bug") + } + t.bug = b +} +func (t *tester) Teardown() { + os.RemoveAll(t.dir) +} func TestTitleToDirectory(t *testing.T) { var assertDirectory = func(title, directory string) { titleStr := TitleToDir(title) @@ -37,3 +52,59 @@ func TestTitleToDirectory(t *testing.T) { assertDirectory("Test --WithSpace", "Test_--WithSpace") assertDirectory("Test - What", "Test_-_What") } + +func TestNewBug(t *testing.T) { + var gdir string + gdir, err := ioutil.TempDir("", "newbug") + if err == nil { + os.Chdir(gdir) + // Hack to get around the fact that /tmp is a symlink on + // OS X, and it causes the directory checks to fail.. + gdir, _ = os.Getwd() + defer os.RemoveAll(gdir) + } else { + t.Error("Failed creating temporary directory for detect") + return + } + os.Mkdir("issues", 0755) + b, err := New("I am a test") + if err != nil || b == nil { + t.Error("Unexpected error when creating New bug" + err.Error()) + } + if b.Dir != GetIssuesDir()+TitleToDir("I am a test") { + t.Error("Unexpected directory when creating New bug") + } +} + +func TestSetDescription(t *testing.T) { + test := tester{} + test.Setup() + defer test.Teardown() + + b := test.bug + + b.SetDescription("Hello, I am a bug.") + val, err := ioutil.ReadFile(string(b.GetDirectory()) + "/Description") + if err != nil { + t.Error("Could not read Description file") + } + + if string(val) != "Hello, I am a bug." { + t.Error("Unexpected description after SetDescription") + } +} + +func TestDescription(t *testing.T) { + test := tester{} + test.Setup() + defer test.Teardown() + + b := test.bug + + desc := "I am yet another bug.\nWith Two Lines." + b.SetDescription(desc) + + if b.Description() != desc { + t.Error("Unexpected result from bug.Description()") + } +} diff --git a/bugs/Directory.go b/bugs/Directory.go index 050a856..611a062 100644 --- a/bugs/Directory.go +++ b/bugs/Directory.go @@ -4,6 +4,7 @@ import ( "os" "regexp" "strings" + "time" ) func GetRootDir() Directory { @@ -32,6 +33,10 @@ func GetRootDir() Directory { } func GetIssuesDir() Directory { + root := GetRootDir() + if root == "" { + return root + } return GetRootDir() + "/issues/" } @@ -55,3 +60,36 @@ func (d Directory) ToTitle() string { return strings.Replace(match, "_", " ", -1) }) } + +func (d Directory) LastModified() time.Time { + var t time.Time + stat, err := os.Stat(string(d)) + if err != nil { + panic("Directory " + string(d) + " is not a directory.") + } + + if stat.IsDir() == false { + return stat.ModTime() + } + + dir, _ := os.Open(string(d)) + files, _ := dir.Readdir(-1) + if len(files) == 0 { + t = stat.ModTime() + } + for _, file := range files { + if file.IsDir() { + mtime := (d + "/" + Directory(file.Name())).LastModified() + if mtime.After(t) { + t = mtime + } + } else { + mtime := file.ModTime() + if mtime.After(t) { + t = mtime + } + + } + } + return t +} diff --git a/bugs/Directory_test.go b/bugs/Directory_test.go new file mode 100644 index 0000000..6348b99 --- /dev/null +++ b/bugs/Directory_test.go @@ -0,0 +1,131 @@ +package bugs + +import ( + "io/ioutil" + "os" + "testing" +) + +func TestGetRootDirWithEnvironmentVariable(t *testing.T) { + var gdir string + gdir, err := ioutil.TempDir("", "rootdirbug") + if err == nil { + os.Chdir(gdir) + // Hack to get around the fact that /tmp is a symlink on + // OS X, and it causes the directory checks to fail.. + gdir, _ = os.Getwd() + defer os.RemoveAll(gdir) + } else { + t.Error("Failed creating temporary directory") + return + } + os.Mkdir("issues", 0755) + os.Setenv("PMIT", "/tmp/abc") + defer os.Unsetenv("PMIT") + dir := GetRootDir() + if dir != Directory("/tmp/abc") { + t.Error("Did not get proper directory according to environment variable") + } +} +func TestGetRootDirFromDirectoryTree(t *testing.T) { + var gdir string + gdir, err := ioutil.TempDir("", "rootdirbug") + if err == nil { + os.Chdir(gdir) + os.Unsetenv("PMIT") + // Hack to get around the fact that /tmp is a symlink on + // OS X, and it causes the directory checks to fail.. + gdir, _ = os.Getwd() + defer os.RemoveAll(gdir) + } else { + t.Error("Failed creating temporary directory") + return + } + // Make sure we get the right directory from the top level + os.Mkdir("issues", 0755) + dir := GetRootDir() + if dir != Directory(gdir) { + t.Error("Did not get proper directory according to walking the tree:" + dir) + } + // Now go deeper into the tree and try the same thing.. + err = os.MkdirAll("abc/123", 0755) + if err != nil { + t.Error("Could not make directory for testing") + } + err = os.Chdir("abc/123") + if err != nil { + t.Error("Could not change directory for testing") + } + dir = GetRootDir() + if dir != Directory(gdir) { + t.Error("Did not get proper directory according to walking the tree:" + dir) + } +} + +func TestNoRoot(t *testing.T) { + var gdir string + gdir, err := ioutil.TempDir("", "rootdirbug") + if err == nil { + os.Chdir(gdir) + // Hack to get around the fact that /tmp is a symlink on + // OS X, and it causes the directory checks to fail.. + gdir, _ = os.Getwd() + defer os.RemoveAll(gdir) + } else { + t.Error("Failed creating temporary directory") + return + } + // Don't create an issues directory. Just try and get the directory + if dir := GetRootDir(); dir != "" { + t.Error("Found unexpected issues directory." + string(dir)) + } + +} + +func TestGetIssuesDir(t *testing.T) { + os.Setenv("PMIT", "/tmp/abc") + defer os.Unsetenv("PMIT") + dir := GetIssuesDir() + if dir != "/tmp/abc/issues/" { + t.Error("Did not get correct issues directory") + } +} +func TestGetNoIssuesDir(t *testing.T) { + var gdir string + gdir, err := ioutil.TempDir("", "rootdirbug") + if err == nil { + os.Chdir(gdir) + // Hack to get around the fact that /tmp is a symlink on + // OS X, and it causes the directory checks to fail.. + gdir, _ = os.Getwd() + defer os.RemoveAll(gdir) + } else { + t.Error("Failed creating temporary directory") + return + } + // Don't create an issues directory. Just try and get the directory + if dir := GetIssuesDir(); dir != "" { + t.Error("Found unexpected issues directory." + string(dir)) + } + +} +func TestShortName(t *testing.T) { + var dir Directory = "/hello/i/am/a/test" + if short := dir.GetShortName(); short != Directory("test") { + t.Error("Unexpected short name: " + string(short)) + } +} +func TestDirectoryToTitle(t *testing.T) { + var assertTitle = func(directory, title string) { + dir := Directory(directory) + if dir.ToTitle() != title { + t.Error("Failed on " + directory + ": got " + dir.ToTitle() + " but expected " + title) + } + } + assertTitle("Test", "Test") + assertTitle("Test-Multiword", "Test Multiword") + assertTitle("Test--Dash", "Test-Dash") + assertTitle("Test---Dash", "Test--Dash") + assertTitle("Test_--TripleDash", "Test --TripleDash") + assertTitle("Test_-_What", "Test - What") +} diff --git a/bugs/Find.go b/bugs/Find.go index 1305969..bf9a546 100644 --- a/bugs/Find.go +++ b/bugs/Find.go @@ -32,6 +32,15 @@ func FindBugsByTag(tags []string) []Bug { return []Bug{} } +func LoadBugByDirectory(dir string) (*Bug, error) { + _, err := ioutil.ReadDir(string(GetRootDir()) + "/issues/" + dir) + if err != nil { + return nil, BugNotFoundError("Could not find bug " + dir) + } + bug := Bug{} + bug.LoadBug(GetIssuesDir() + Directory(dir)) + return &bug, nil +} func LoadBugByHeuristic(id string) (*Bug, error) { issues, _ := ioutil.ReadDir(string(GetRootDir()) + "/issues") diff --git a/bugs/Formats.go b/bugs/Formats.go new file mode 100644 index 0000000..41f161b --- /dev/null +++ b/bugs/Formats.go @@ -0,0 +1,31 @@ +package bugs + +import ( + "encoding/json" +) + +func (b Bug) ToJSONString() (string, error) { + bJSONStruct := struct { + Identifier string `json:",omitempty"` + Title string + Description string + Status string `json:",omitempty"` + Priority string `json:",omitempty"` + Milestone string `json:",omitempty"` + Tags []string `json:",omitempty"` + }{ + Identifier: b.Identifier(), + Title: b.Title(""), + Description: b.Description(), + Status: b.Status(), + Priority: b.Priority(), + Milestone: b.Milestone(), + Tags: b.StringTags(), + } + + bJSON, err := json.Marshal(bJSONStruct) + if err != nil { + return "", err + } + return string(bJSON), nil +} diff --git a/bugs/IO.go b/bugs/IO.go new file mode 100644 index 0000000..3eb3d73 --- /dev/null +++ b/bugs/IO.go @@ -0,0 +1,64 @@ +package bugs + +import ( + "fmt" + "os" +) + +func (b *Bug) Read(p []byte) (int, error) { + if b.descFile == nil { + dir := b.GetDirectory() + fp, err := os.OpenFile(string(dir)+"/Description", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) + b.descFile = fp + if err != nil { + fmt.Fprintf(os.Stderr, "err: %s", err.Error()) + return 0, NoDescriptionError + } + } + + return b.descFile.Read(p) +} + +func (b *Bug) Write(data []byte) (n int, err error) { + if b.descFile == nil { + dir := b.GetDirectory() + os.MkdirAll(string(dir), 0755) + fp, err := os.OpenFile(string(dir)+"/Description", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "Error writing to bug: %s", err.Error()) + return 0, err + } + b.descFile = fp + } + return b.descFile.Write(data) +} + +func (b *Bug) WriteAt(data []byte, off int64) (n int, err error) { + if b.descFile == nil { + dir := b.GetDirectory() + os.MkdirAll(string(dir), 0755) + fp, err := os.OpenFile(string(dir)+"/Description", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "Error writing to bug: %s", err.Error()) + return 0, err + } + b.descFile = fp + } + return b.descFile.WriteAt(data, off) +} +func (b Bug) Close() error { + if b.descFile != nil { + err := b.descFile.Close() + b.descFile = nil + return err + } + return nil +} + +func (b *Bug) Remove() error { + dir := b.GetDirectory() + if dir != "" { + return os.RemoveAll(string(dir)) + } + return NotFoundError +} diff --git a/bugs/IO_test.go b/bugs/IO_test.go new file mode 100644 index 0000000..f91ec86 --- /dev/null +++ b/bugs/IO_test.go @@ -0,0 +1,49 @@ +package bugs + +import ( + bugs "." + "fmt" + "io/ioutil" + "os" + "testing" +) + +func TestBugWrite(t *testing.T) { + var b *Bug + if dir, err := ioutil.TempDir("", "BugWrite"); err == nil { + os.Chdir(dir) + b = &Bug{Dir: Directory(dir + "/issues/Test-bug")} + defer os.RemoveAll(dir) + } else { + t.Error("Could not get temporary directory to test bug write()") + return + } + + _, err := b.Write([]byte("Hello there, Mr. Test")) + if err != nil { + t.Error("Error writing to bug at %s.", b.Dir) + } + b.Close() + + fp, _ := os.Open("issues/Test-bug/Description") + desc, err := ioutil.ReadAll(fp) + fp.Close() + + if err != nil { + t.Error("Error reading description file.") + return + } + + // Cast the values to strings because []byte complains that + // slices can only be compared to nil. + if string(desc) != string("Hello there, Mr. Test") { + t.Error("Incorrect description file after writing to bug") + } +} + +func ExampleBugWriter() { + if b, err := bugs.New("Bug Title"); err != nil { + fmt.Fprintf(b, "This is a bug report.\n") + fmt.Fprintf(b, "The bug will be created as necessary.\n") + } +} diff --git a/issues/Bug.Close-should-be-able-to-notify-people/Description b/issues/Bug.Close-should-be-able-to-notify-people/Description new file mode 100644 index 0000000..d6aa579 --- /dev/null +++ b/issues/Bug.Close-should-be-able-to-notify-people/Description @@ -0,0 +1,6 @@ +When a bug is closed with bug.Close(), instead of +just closing the file descriptor, there should +be a method of notifying things other than the VCS +that a bug was created (ie. email, send a post +to a url, etc) so make it more useful to embed +the bugs library into other applications. diff --git a/issues/Bug.Close-should-be-able-to-notify-people/Milestone b/issues/Bug.Close-should-be-able-to-notify-people/Milestone new file mode 100644 index 0000000..4ea1309 --- /dev/null +++ b/issues/Bug.Close-should-be-able-to-notify-people/Milestone @@ -0,0 +1 @@ +v0.5 \ No newline at end of file diff --git a/issues/Implement-golang-io-interfaces-in-Bug-class/Description b/issues/Implement-golang-io-interfaces-in-Bug-class/Description deleted file mode 100644 index 2314c16..0000000 --- a/issues/Implement-golang-io-interfaces-in-Bug-class/Description +++ /dev/null @@ -1,15 +0,0 @@ -The bugs.Bug class should implement some common -go interfaces so that you can easily intergrate -bug with other programs/libraries. In particular, -the interfaces: - -1. io.Writer should append to the bug description -2. io.WriterAt should modify the description -3. io.Reader should print either the bug description - or the full bug list BugIdx style output (not sure - which, maybe have a flag in the bug struct to modify - the two.) - -This would make it much easier to integrate bugs -with logging systems when errors are encounterd by using -ie. io.MultiWriter diff --git a/issues/Implement-golang-io-interfaces-in-Bug-class/Milestone b/issues/Implement-golang-io-interfaces-in-Bug-class/Milestone deleted file mode 100644 index 866c31a..0000000 --- a/issues/Implement-golang-io-interfaces-in-Bug-class/Milestone +++ /dev/null @@ -1 +0,0 @@ -v0.4 \ No newline at end of file diff --git a/issues/Implement-golang-io-interfaces-in-Bug-class/Status b/issues/Implement-golang-io-interfaces-in-Bug-class/Status deleted file mode 100644 index 4866642..0000000 --- a/issues/Implement-golang-io-interfaces-in-Bug-class/Status +++ /dev/null @@ -1 +0,0 @@ -In Progress on branch IOInterfaces \ No newline at end of file diff --git a/issues/Roadmap.md b/issues/Roadmap.md index 0a5848c..b7af0ec 100644 --- a/issues/Roadmap.md +++ b/issues/Roadmap.md @@ -1,5 +1,7 @@ # Roadmap for bug +## v0.5: +- Bug.Close should be able to notify people + ## v0.4: - bug-serve should have feature parity with bug -- Implement golang io interfaces in Bug class diff --git a/issues/bug--serve-should-have-feature-parity-with-bug/Description b/issues/bug--serve-should-have-feature-parity-with-bug/Description deleted file mode 100644 index 321bd49..0000000 --- a/issues/bug--serve-should-have-feature-parity-with-bug/Description +++ /dev/null @@ -1,3 +0,0 @@ -bug-serve, the HTTP implementation of bug, should -support anything that you can do from the command -line. diff --git a/issues/bug--serve-should-have-feature-parity-with-bug/Milestone b/issues/bug--serve-should-have-feature-parity-with-bug/Milestone deleted file mode 100644 index 866c31a..0000000 --- a/issues/bug--serve-should-have-feature-parity-with-bug/Milestone +++ /dev/null @@ -1 +0,0 @@ -v0.4 \ No newline at end of file diff --git a/issues/bug--serve-should-have-feature-parity-with-bug/Status b/issues/bug--serve-should-have-feature-parity-with-bug/Status deleted file mode 100644 index e69de29..0000000 diff --git a/issues/bug--serve-should-have-feature-parity-with-bug/tags/feature b/issues/bug--serve-should-have-feature-parity-with-bug/tags/feature deleted file mode 100644 index e69de29..0000000 diff --git a/main.go b/main.go index 7b91b49..f5da8cd 100644 --- a/main.go +++ b/main.go @@ -5,49 +5,31 @@ import ( "github.com/driusan/bug/bugapp" "github.com/driusan/bug/bugs" "os" - "os/exec" - "runtime" - // "bytes" - // "io" ) func main() { - if bugs.GetRootDir() == "" { + var skipRootCheck bool = false + switch len(os.Args) { + case 0, 1: + skipRootCheck = true + case 2: + if os.Args[1] == "help" { + skipRootCheck = true + } + case 3: + if os.Args[2] == "--help" { + skipRootCheck = true + } + + } + if skipRootCheck == false && bugs.GetRootDir() == "" { fmt.Printf("Could not find issues directory.\n") fmt.Printf("Make sure either the PMIT environment variable is set, or a parent directory of your working directory has an issues folder.\n") + fmt.Println("(If you just started new repo, you probably want to create directory named `issues`).") fmt.Printf("Aborting.\n") os.Exit(2) - } - // Create a pipe for a pager to use - r, w, err := os.Pipe() - if err != nil { - fmt.Fprintln(os.Stderr, err) } - // Capture STDOUT for the Pager - stdout := os.Stdout - - // Don't capture the output on MacOS, because for some reason - // it doesn't work and results in nothing getting printed - if runtime.GOOS != "darwin" { - os.Stdout = w - } - - // Invoke less -RF attached to the pipe - // we created - cmd := exec.Command("less", "-RF") - cmd.Stdin = r - cmd.Stdout = stdout - cmd.Stderr = os.Stderr - // Make sure the pipe is closed after we - // finish, then restore STDOUT - defer func() { - w.Close() - if err := cmd.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) - } - os.Stdout = stdout - }() if len(os.Args) > 1 { if len(os.Args) >= 3 && os.Args[2] == "--help" { @@ -55,13 +37,12 @@ func main() { } switch os.Args[1] { case "add", "new", "create": - os.Stdout = stdout bugapp.Create(os.Args[2:]) case "view", "list": // bug list with no parameters shouldn't autopage, // bug list with bugs to view should. So the original // stdout is passed as a parameter. - bugapp.List(os.Args[2:], stdout) + bugapp.List(os.Args[2:]) case "priority": bugapp.Priority(os.Args[2:]) case "status": @@ -75,15 +56,10 @@ func main() { case "mv", "rename", "retitle", "relabel": bugapp.Relabel(os.Args[2:]) case "purge": - // This shouldn't autopage - os.Stdout = stdout bugapp.Purge() case "rm", "close": bugapp.Close(os.Args[2:]) case "edit": - // Edit needs the original Stdout since it - // invokes an editor - os.Stdout = stdout bugapp.Edit(os.Args[2:]) case "--version", "version": bugapp.Version() diff --git a/scm/Detect.go b/scm/Detect.go index 927d23a..b089389 100644 --- a/scm/Detect.go +++ b/scm/Detect.go @@ -38,10 +38,14 @@ func DetectSCM(options map[string]bool) (SCMHandler, bugs.Directory, error) { dirFound, scmtype := walkAndSearch(wd, []string{".git", ".hg"}) if dirFound != "" && scmtype == ".git" { - if val, exists := options["autoclose"]; exists && val == true { - return GitManager{Autoclose: true}, bugs.Directory(dirFound), nil + var gm GitManager + if val, ok := options["autoclose"]; ok { + gm.Autoclose = val } - return GitManager{Autoclose: false}, bugs.Directory(dirFound), nil + if val, ok := options["use_bug_prefix"]; ok { + gm.UseBugPrefix = val + } + return gm, bugs.Directory(dirFound), nil } if dirFound != "" && scmtype == ".hg" { return HgManager{}, bugs.Directory(dirFound), nil diff --git a/scm/Detect_test.go b/scm/Detect_test.go new file mode 100644 index 0000000..354099a --- /dev/null +++ b/scm/Detect_test.go @@ -0,0 +1,110 @@ +package scm + +import ( + "github.com/driusan/bug/bugs" + "io/ioutil" + "os" + "testing" +) + +func TestDetectGit(t *testing.T) { + var gdir string + gdir, err := ioutil.TempDir("", "gitdetect") + if err == nil { + os.Chdir(gdir) + // Hack to get around the fact that /tmp is a symlink on + // OS X, and it causes the directory checks to fail.. + gdir, _ = os.Getwd() + defer os.RemoveAll(gdir) + } else { + t.Error("Failed creating temporary directory for detect") + return + } + // Fake a git repo + os.Mkdir(".git", 0755) + + options := make(map[string]bool) + handler, dir, err := DetectSCM(options) + if err != nil { + t.Error("Unexpected while detecting repo type: " + err.Error()) + } + if dir != bugs.Directory(gdir+"/.git") { + t.Error("Unexpected directory found when trying to detect git repo" + dir) + } + switch handler.(type) { + case GitManager: + // GitManager is what we expect, don't fall through + // to the error + default: + t.Error("Unexpected SCMHandler found for Git") + } + + // Go somewhere higher in the tree and do it again + os.MkdirAll("tmp/abc/hello", 0755) + os.Chdir("tmp/abc/hello") + handler, dir, err = DetectSCM(options) + if err != nil { + t.Error("Unexpected while detecting repo type: " + err.Error()) + } + if dir != bugs.Directory(gdir+"/.git") { + t.Error("Unexpected directory found when trying to detect git repo" + dir) + } + switch handler.(type) { + case GitManager: + // GitManager is what we expect, don't fall through + // to the error + default: + t.Error("Unexpected SCMHandler found for Git") + } +} + +func TestDetectHg(t *testing.T) { + var gdir string + gdir, err := ioutil.TempDir("", "hgdetect") + if err == nil { + os.Chdir(gdir) + // Hack to get around the fact that /tmp is a symlink on + // OS X, and it causes the directory checks to fail.. + gdir, _ = os.Getwd() + defer os.RemoveAll(gdir) + } else { + t.Error("Failed creating temporary directory for detect") + return + } + // Fake a git repo + os.Mkdir(".hg", 0755) + + options := make(map[string]bool) + handler, dir, err := DetectSCM(options) + if err != nil { + t.Error("Unexpected while detecting repo type: " + err.Error()) + } + if dir != bugs.Directory(gdir+"/.hg") { + t.Error("Unexpected directory found when trying to detect git repo" + dir) + } + switch handler.(type) { + case HgManager: + // HgManager is what we expect, don't fall through + // to the error + default: + t.Error("Unexpected SCMHandler found for Mercurial") + } + + // Go somewhere higher in the tree and do it again + os.MkdirAll("tmp/abc/hello", 0755) + os.Chdir("tmp/abc/hello") + handler, dir, err = DetectSCM(options) + if err != nil { + t.Error("Unexpected while detecting repo type: " + err.Error()) + } + if dir != bugs.Directory(gdir+"/.hg") { + t.Error("Unexpected directory found when trying to detect git repo" + dir) + } + switch handler.(type) { + case HgManager: + // GitManager is what we expect, don't fall through + // to the error + default: + t.Error("Unexpected SCMHandler found for Mercurial") + } +} diff --git a/scm/GitManager.go b/scm/GitManager.go index 4710c38..fb54c64 100644 --- a/scm/GitManager.go +++ b/scm/GitManager.go @@ -1,6 +1,7 @@ package scm import ( + "bytes" "fmt" "github.com/driusan/bug/bugs" "io/ioutil" @@ -10,26 +11,9 @@ import ( "strings" ) -type PreconditionFailed string - -func (a PreconditionFailed) Error() string { - return string(a) -} - -type ExecutionFailed string - -func (a ExecutionFailed) Error() string { - return string(a) -} - -type UnsupportedType string - -func (a UnsupportedType) Error() string { - return string(a) -} - type GitManager struct { - Autoclose bool + Autoclose bool + UseBugPrefix bool } func (a GitManager) Purge(dir bugs.Directory) error { @@ -38,35 +22,144 @@ func (a GitManager) Purge(dir bugs.Directory) error { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - err := cmd.Run() + return cmd.Run() +} - if err != nil { - return err - } - return nil +type issueStatus struct { + a, d, m bool // Added, Deleted, Modified } +type issuesStatus map[string]issueStatus + +// Get list of created, updated, closed and closed-on-github issues. +// +// In general following rules to categorize issues are applied: +// * closed if Description file is deleted (D); +// * created if Description file is created (A) (TODO: handle issue renamings); +// * closed issue will also close issue on GH when Autoclose is true (see Identifier example); +// * updated if Description file is modified (M); +// * updated if Description is unchanged but any other files are touched. (' '+x) +// +// eg output from `from git status --porcelain`, appendix mine +// note that `git add -A issues` was invoked before +// +// D issues/First-GH-issue/Description issue closed (GH issues are also here) +// D issues/First-GH-issue/Identifier maybe it is GH issue, maybe not +// M issues/issue--2/Description desc updated +// A issues/issue--2/Status new field added (status); considered as update unless Description is also created +// D issues/issue1/Description issue closed +// A issues/issue3/Description new issue, description field is mandatory for rich format +func (a GitManager) currentStatus(dir bugs.Directory) (closedOnGitHub []string, _ issuesStatus) { + ghRegex := regexp.MustCompile("(?im)^-Github:(.*)$") + closesGH := func(file string) (issue string, ok bool) { + if !a.Autoclose { + return "", false + } + if !strings.HasSuffix(file, "Identifier") { + return "", false + } + diff := exec.Command("git", "diff", "--staged", "--", file) + diffout, _ := diff.CombinedOutput() + matches := ghRegex.FindStringSubmatch(string(diffout)) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]), true + } + return "", false + } + short := func(path string) string { + b := strings.Index(path, "/") + e := strings.LastIndex(path, "/") + if b+1 >= e { + return "???" + } + return path[b+1 : e] + } -func (a GitManager) getDeletedIdentifiers(dir bugs.Directory) []string { cmd := exec.Command("git", "status", "-z", "--porcelain", string(dir)) out, _ := cmd.CombinedOutput() files := strings.Split(string(out), "\000") - retVal := []string{} + + issues := issuesStatus{} + var ghClosed []string + const minLineLen = 3 /*for path*/ + 2 /*for issues dir with path sep*/ + 3 /*for issue name, path sep and any file under issue dir*/ for _, file := range files { - if file == "" { + if len(file) < minLineLen { continue } - if file[0:1] == "D" && strings.HasSuffix(file, "Identifier") { - ghRegex := regexp.MustCompile("(?im)^-Github:(\\s*)(.*)(\\s*)$^") - diff := exec.Command("git", "diff", "--staged", "--", file[3:]) - diffout, _ := diff.CombinedOutput() - if matches := ghRegex.FindStringSubmatch(string(diffout)); len(matches) > 2 { - retVal = append(retVal, matches[2]) + + path := file[3:] + op := file[0] + desc := strings.HasSuffix(path, "/Description") + name := short(path) + issue := issues[name] + + switch { + case desc && op == 'D': + issue.d = true + case desc && op == 'A': + issue.a = true + default: + issue.m = true + if op == 'D' { + if ghIssue, ok := closesGH(path); ok { + ghClosed = append(ghClosed, ghIssue) + issue.d = true // to be sure + } } } + + issues[name] = issue } - return retVal + return ghClosed, issues } -func (a GitManager) Commit(dir bugs.Directory, commitMsg string) error { + +// Create commit message by iterate over issues in order: +// closed issues are most important (something is DONE, ok? ;), those issues will also become hidden) +// new issues are next, with just updates at the end +// TODO: do something if this message will be too long +func (a GitManager) commitMsg(dir bugs.Directory) []byte { + ghClosed, issues := a.currentStatus(dir) + + done, add, update, together := &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{} + var cntd, cnta, cntu int + + for issue, state := range issues { + if state.d { + fmt.Fprintf(done, ", %q", issue) + cntd++ + } else if state.a { + fmt.Fprintf(add, ", %q", issue) + cnta++ + } else if state.m { + fmt.Fprintf(update, ", %q", issue) + cntu++ + } + } + + f := func(b *bytes.Buffer, what string, many bool) { + if b.Len() == 0 { + return + } + var m string + if many { + m = "s:" + } + s := b.Bytes()[2:] + fmt.Fprintf(together, "%s issue%s %s; ", what, m, s) + } + f(done, "Close", cntd > 1) + f(add, "Create", cnta > 1) + f(update, "Update", cntu > 1) + if l := together.Len(); l > 0 { + together.Truncate(l - 2) // "; " from last applied f() + } + + if len(ghClosed) > 0 { + fmt.Fprintf(together, "\n\nCloses %s\n", strings.Join(ghClosed, ", closes ")) + } + return together.Bytes() +} + +func (a GitManager) Commit(dir bugs.Directory, backupCommitMsg string) error { cmd := exec.Command("git", "add", "-A", string(dir)) if err := cmd.Run(); err != nil { fmt.Printf("Could not add issues to be commited: %s?\n", err.Error()) @@ -74,25 +167,24 @@ func (a GitManager) Commit(dir bugs.Directory, commitMsg string) error { } - var deletedIdentifiers []string - if a.Autoclose == true { - deletedIdentifiers = a.getDeletedIdentifiers(dir) - } else { - deletedIdentifiers = []string{} - } - if len(deletedIdentifiers) > 0 { - commitMsg = fmt.Sprintf("%s\n\nCloses %s\n", commitMsg, strings.Join(a.getDeletedIdentifiers(dir), ", closes ")) - } else { - commitMsg = fmt.Sprintf("%s\n", commitMsg) - } + msg := a.commitMsg(dir) + file, err := ioutil.TempFile("", "bugCommit") if err != nil { - fmt.Fprintf(os.Stderr, "Could not create file for commit message.\n") + fmt.Fprintf(os.Stderr, "Could not create temporary file.\nNothing commited.\n") + return err + } + defer os.Remove(file.Name()) + + if len(msg) == 0 { + fmt.Fprintf(file, "%s\n", backupCommitMsg) + } else { + var pref string + if a.UseBugPrefix { + pref = "bug: " + } + fmt.Fprintf(file, "%s%s\n", pref, msg) } - defer func() { - os.Remove(file.Name()) - }() - file.WriteString(commitMsg) cmd = exec.Command("git", "commit", "-o", string(dir), "-F", file.Name(), "-q") if err := cmd.Run(); err != nil { // If nothing was added commit will have an error, diff --git a/scm/GitManager_test.go b/scm/GitManager_test.go index 8559c8e..d7d8f0f 100644 --- a/scm/GitManager_test.go +++ b/scm/GitManager_test.go @@ -2,6 +2,7 @@ package scm import ( "fmt" + "github.com/driusan/bug/bugs" "io/ioutil" "os" "strings" @@ -23,6 +24,10 @@ func (c GitCommit) Diff() (string, error) { return runCmd("git", "show", "--pretty=format:%b", c.CommitID()) } +func (c GitCommit) CommitMessage() (string, error) { + return runCmd("git", "show", "--pretty=format:%B", "--quiet", c.CommitID()) +} + type GitTester struct { handler SCMHandler workdir string @@ -31,7 +36,8 @@ type GitTester struct { func (t GitTester) GetLogs() ([]Commit, error) { logs, err := runCmd("git", "log", "--oneline", "--reverse", "-z") if err != nil { - fmt.Fprintf(os.Stderr, "Error retrieving git logs: %s", logs) + wd, _ := os.Getwd() + fmt.Fprintf(os.Stderr, "Error retrieving git logs: %s in directory %s\n", logs, wd) return nil, err } logMsgs := strings.Split(logs, "\000") @@ -76,13 +82,12 @@ func (t *GitTester) Setup() error { return err } - out, err := runCmd("git", "init", ".") + out, err := runCmd("git", "init") if err != nil { fmt.Fprintf(os.Stderr, "Error initializing git: %s", out) return err } - t.handler = GitManager{} return nil } func (t GitTester) TearDown() { @@ -107,10 +112,6 @@ func (m GitTester) GetManager() SCMHandler { } func TestGitBugRenameCommits(t *testing.T) { - if os.Getenv("TRAVIS") == "true" && os.Getenv("TRAVIS_OS_NAME") == "linux" { - t.Skip("Skipping test which fails only under Travis for unknown reasons..") - return - } gm := GitTester{} gm.handler = GitManager{} @@ -144,3 +145,63 @@ func TestGitManagerGetType(t *testing.T) { t.Error("Incorrect SCM Type for GitManager. Got " + getType) } } + +func TestGitManagerPurge(t *testing.T) { + gm := GitTester{} + gm.handler = GitManager{} + runtestPurgeFiles(&gm, t) +} + +func TestGitManagerAutoclosingGitHub(t *testing.T) { + // This test is specific to gitmanager, since GitHub + // only supports git.. + tester := GitTester{} + tester.handler = GitManager{Autoclose: true} + + err := tester.Setup() + if err != nil { + panic("Something went wrong trying to initialize git:" + err.Error()) + } + defer tester.TearDown() + m := tester.GetManager() + if m == nil { + t.Error("Could not get manager") + return + } + os.Mkdir("issues", 0755) + runCmd("bug", "create", "-n", "Test", "bug") + runCmd("bug", "create", "-n", "Test", "Another", "bug") + if err = ioutil.WriteFile("issues/Test-bug/Identifier", []byte("\n\nGitHub:#TestBug"), 0644); err != nil { + t.Error("Could not write Identifier file") + return + } + if err = ioutil.WriteFile("issues/Test-Another-bug/Identifier", []byte("\n\nGITHuB: #Whitespace "), 0644); err != nil { + t.Error("Could not write Identifier file") + return + } + + // Commit the file, so that we can close it.. + m.Commit(bugs.Directory(tester.GetWorkDir()+"/issues"), "Adding commit") + // Delete the bug + os.RemoveAll(tester.GetWorkDir() + "/issues/Test-bug") + os.RemoveAll(tester.GetWorkDir() + "/issues/Test-Another-bug") + m.Commit(bugs.Directory(tester.GetWorkDir()+"/issues"), "Removal commit") + + commits, err := tester.GetLogs() + if len(commits) != 2 || err != nil { + t.Error("Error getting git logs while attempting to test GitHub autoclosing") + return + } + if msg, err := commits[1].(GitCommit).CommitMessage(); err != nil { + t.Error("Error getting git logs while attempting to test GitHub autoclosing") + } else { + closing := func(issue string) bool { + return strings.Contains(msg, "Closes #"+issue) || + strings.Contains(msg, ", closes #"+issue) + } + if !closing("Whitespace") || !closing("TestBug") { + fmt.Printf("%s\n", msg) + t.Error("GitManager did not autoclose Github issues") + } + } +} diff --git a/scm/HgManager_test.go b/scm/HgManager_test.go index f3e828a..9ccef98 100644 --- a/scm/HgManager_test.go +++ b/scm/HgManager_test.go @@ -124,6 +124,34 @@ deleted file mode 100644 } func TestHgFilesOutsideOfBugNotCommited(t *testing.T) { tester := HgTester{} - tester.handler = GitManager{} + tester.handler = HgManager{} runtestCommitDirtyTree(&tester, t) } + +func TestHgGetType(t *testing.T) { + m := HgManager{} + + if m.GetSCMType() != "hg" { + t.Error("Incorrect type for HgManager") + } +} + +func TestHgPurge(t *testing.T) { + // This should eventually be replaced by something more + // like: + // m := HgTester{} + // m.handler = HgManager{} + // runtestPurgeFiles(&m, t) + // but since the current behaviour is to return a not + // supported error, that would evidently fail.. + m := HgManager{} + err := m.Purge("/tmp/imaginaryHgRepo") + + switch err.(type) { + case UnsupportedType: + // This is valid, do nothing. + default: + t.Error("Unexpected return value for Hg purge function.") + } + +} diff --git a/scm/TestHelpers_test.go b/scm/TestHelpers_test.go index d3eead5..f3a9823 100644 --- a/scm/TestHelpers_test.go +++ b/scm/TestHelpers_test.go @@ -33,7 +33,7 @@ func runCmd(cmd string, options ...string) (string, error) { return string(out), err } -func assertLogs(tester ManagerTester, t *testing.T, titles []string, diffs []string) { +func assertLogs(tester ManagerTester, t *testing.T, titles []map[string]bool, diffs []string) { logs, err := tester.GetLogs() if err != nil { t.Error("Could not get scm logs" + err.Error()) @@ -50,7 +50,7 @@ func assertLogs(tester ManagerTester, t *testing.T, titles []string, diffs []str } for i, _ := range titles { - if titles[i] != logs[i].LogMsg() { + if _, ok := titles[i][logs[i].LogMsg()]; !ok { t.Error("Unexpected commit message:" + logs[i].LogMsg()) } @@ -58,7 +58,14 @@ func assertLogs(tester ManagerTester, t *testing.T, titles []string, diffs []str t.Error("Could not get diff of commit") } else { if diff != diffs[i] { - t.Error("Incorrect diff for " + titles[i]) + // get shortest commit msg to keep errors simple + var s string + for k := range titles[i] { + if len(s) == 0 || len(k) < len(s) { + s = k + } + } + t.Error(fmt.Sprintf("Incorrect diff for i=%d, title=%s", i, s)) fmt.Fprintf(os.Stderr, "Got %s, expected %s", diff, diffs[i]) } } @@ -86,7 +93,14 @@ func runtestRenameCommitsHelper(tester ManagerTester, t *testing.T, expectedDiff tester.AssertCleanTree(t) - assertLogs(tester, t, []string{"Initial commit", "This is a test rename"}, expectedDiffs) + assertLogs(tester, t, []map[string]bool{{ + "Initial commit": true, // simple format + `Create issue "Test-bug"`: true, // rich format + }, { + "This is a test rename": true, // simple format + `Update issues: "Test-bug", "Renamed-bug"`: true, // rich format + `Update issues: "Renamed-bug", "Test-bug"`: true, // has two alternatives equally good + }}, expectedDiffs) } func runtestCommitDirtyTree(tester ManagerTester, t *testing.T) { @@ -123,3 +137,37 @@ func runtestCommitDirtyTree(tester ManagerTester, t *testing.T) { FileStatus{"donotcommit.txt", "A", " "}, }) } + +func runtestPurgeFiles(tester ManagerTester, t *testing.T) { + err := tester.Setup() + if err != nil { + panic("Something went wrong trying to initialize: " + err.Error()) + } + defer tester.TearDown() + m := tester.GetManager() + if m == nil { + t.Error("Could not get manager") + return + } + os.Mkdir("issues", 0755) + // Commit a bug which should stay around after the purge + runCmd("bug", "create", "-n", "Test", "bug") + m.Commit(bugs.Directory(tester.GetWorkDir()+"/issues"), "Initial commit") + + // Create another bug to elimate with "bug purge" + runCmd("bug", "create", "-n", "Test", "purge", "bug") + err = m.Purge(bugs.Directory(tester.GetWorkDir() + "/issues")) + if err != nil { + t.Error("Error purging bug directory: " + err.Error()) + } + issuesDir, err := ioutil.ReadDir("issues") //fmt.Sprintf("%s/issues/", tester.GetWorkDir())) + if err != nil { + t.Error("Error reading issues directory") + } + if len(issuesDir) != 1 { + t.Error("Unexpected number of directories in issues/ after purge.") + } + if len(issuesDir) > 0 && issuesDir[0].Name() != "Test-bug" { + t.Error("Expected Test-bug to remain.") + } +} diff --git a/scm/errors.go b/scm/errors.go new file mode 100644 index 0000000..d3da5d0 --- /dev/null +++ b/scm/errors.go @@ -0,0 +1,7 @@ +package scm + +type UnsupportedType string + +func (a UnsupportedType) Error() string { + return string(a) +}