diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..10ed3b3 --- /dev/null +++ b/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "flag" + "fmt" + "github.com/mpittkin/go-todo/output" + "github.com/mpittkin/go-todo/todo" + "io/fs" + "log" + "os" + "path/filepath" +) + +type config struct { + RootPath string + OutputType string + SlackWebhookURL string + RepoTitle string +} + +const ( + outTypeConsole = "console" + outTypeSlackWebhook = "slack-webhook" +) + +const ( + defaultRootPath = "." + defaultOutputType = "console" + defaultSlackWebhookURL = "" +) + +func main() { + cfg := config{ + RootPath: defaultRootPath, + OutputType: defaultOutputType, + SlackWebhookURL: defaultSlackWebhookURL, + } + if r := os.Getenv("GOTODO_ROOT_PATH"); r != "" { + cfg.RootPath = r + } + if o := os.Getenv("GOTODO_OUTPUT_TYPE"); o != "" { + cfg.OutputType = o + } + if u := os.Getenv("GOTODO_SLACK_URL"); u != "" { + cfg.SlackWebhookURL = u + } + cfg.RepoTitle = os.Getenv("GOTODO_REPO_TITLE") + + pathFlag := flag.String("root-path", "", "the root path from which directories will be traversed looking for files to parse") + outputFlag := flag.String("output-type", "", "the output type (console, json, or slack-webhook") + webhookFlag := flag.String("slack-webhook-url", "", "when output type is set to 'slack-webhook' defines the url to send the POST request") + titleFlag := flag.String("repo-title", "", "when output type is set to slack-webhook, this is included in the report to indicate to the reader the source of the todos") + + flag.Parse() + if *pathFlag != "" { + cfg.RootPath = *pathFlag + } + if *outputFlag != "" { + cfg.OutputType = *outputFlag + } + if *webhookFlag != "" { + cfg.SlackWebhookURL = *webhookFlag + } + if *titleFlag != "" { + cfg.SlackWebhookURL = *titleFlag + } + + rootPath := "." + if len(os.Args) > 1 { + rootPath = os.Args[1] + if err := os.Chdir(rootPath); err != nil { + log.Fatalf("Unable to change directory to %s\n", rootPath) + } + } + + absPath, err := filepath.Abs(rootPath) + if err != nil { + log.Fatalf("Unable to convert path '%s' to absolute path: %s", rootPath, err) + } + fmt.Println("Starting at " + absPath) + + var result []todo.Todo + + // Walk through the files and process all .go files + err = fs.WalkDir(os.DirFS(absPath), ".", func(path string, d fs.DirEntry, err error) error { + // Skip walking .git because it contains so many small files it slows down the program substantially + if d.IsDir() { + if d.Name() == ".git" { + return fs.SkipDir + } + return nil + } + + ext := filepath.Ext(path) + if ext != ".go" { + return nil + } + + todos, err := todo.ParseGo(path) + if err != nil { + log.Fatalf("parse %s: %s", path, err) + } + + result = append(result, todos...) + + return nil + }) + if err != nil { + log.Fatalf("walk path %s: %s", absPath, err) + } + + switch cfg.OutputType { + case outTypeConsole: + output.ToConsole(result) + case outTypeSlackWebhook: + if err := output.ToSlackWebhook(result, cfg.SlackWebhookURL, cfg.RepoTitle); err != nil { + log.Fatalf("error posting result to slack webhook: %s", err) + } + fmt.Println("Todo report sent successfully to Slack") + default: + log.Printf("invalid output type %s\n", cfg.OutputType) + } +} diff --git a/output/console.go b/output/console.go new file mode 100644 index 0000000..0a7c27d --- /dev/null +++ b/output/console.go @@ -0,0 +1,14 @@ +package output + +import ( + "fmt" + "github.com/mpittkin/go-todo/todo" +) + +func ToConsole(todos []todo.Todo) { + for _, todo := range todos { + fmt.Println(todo) + } + + fmt.Printf("Found %d todos\n", len(todos)) +} diff --git a/output/slack.go b/output/slack.go new file mode 100644 index 0000000..1fd8279 --- /dev/null +++ b/output/slack.go @@ -0,0 +1,84 @@ +package output + +import ( + "fmt" + "github.com/mpittkin/go-todo/todo" + "io" + "log" + "net/http" + "sort" + "strings" +) + +type AuthorTodos struct { + AuthorMail string + Todos []todo.Todo +} + +func ToSlackWebhook(todos []todo.Todo, webhookUrl string, repo string) error { + byAuthor := make(map[string][]todo.Todo) + for _, td := range todos { + authorTodos := byAuthor[td.Mail] + authorTodos = append(authorTodos, td) + byAuthor[td.Mail] = authorTodos + } + + var byAuthorSl []AuthorTodos + for authorMail, authorTodos := range byAuthor { + byAuthorSl = append(byAuthorSl, AuthorTodos{ + AuthorMail: authorMail, + Todos: authorTodos, + }) + } + + sort.Slice(byAuthorSl, func(i, j int) bool { + return byAuthorSl[i].AuthorMail < byAuthorSl[j].AuthorMail + }) + + message := fmt.Sprintf(` +Todo Report: %s +Total Todos: %d\n +`, repo, len(todos)) + + for _, auth := range byAuthorSl { + authorBlock := fmt.Sprintf(` +*%s* +`, auth.AuthorMail) + for _, td := range auth.Todos { + + authorBlock += fmt.Sprintf("%s:%d (%v) `%s`\\n\n", td.Path, td.Line, td.Time.Format("2006-01-02"), td.Text) + } + + message += authorBlock + } + + body := fmt.Sprintf(`{ "text": "%s"}`, message) + + if err := PostToWebhook(webhookUrl, strings.NewReader(body)); err != nil { + return fmt.Errorf("post to slack webhook %s: %w", webhookUrl, err) + } + + return nil +} + +func PostToWebhook(url string, body io.Reader) error { + req, err := http.NewRequest(http.MethodPost, url, body) + if err != nil { + return err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { + err := resp.Body.Close() + if err != nil { + log.Printf("close response body: %s", err) + } + }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("http response error %s", resp.Status) + } + return nil +} diff --git a/todo/git.go b/todo/git.go new file mode 100644 index 0000000..63762cd --- /dev/null +++ b/todo/git.go @@ -0,0 +1,58 @@ +package todo + +import ( + "bufio" + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) + +const ( + pfxAuthorName = "author " + pfxAuthorMail = "author-mail " + pfxTime = "author-time " +) + +func Blame(path string, lineNo int) (BlameInfo, error) { + var blameInfo BlameInfo + lineArg := fmt.Sprintf("-L %d,%d", lineNo, lineNo) + args := []string{ + "blame", + "--porcelain", + "--incremental", + lineArg, + path, + } + + cmd := exec.Command("git", args...) + + stdOut, err := cmd.StdoutPipe() + if err != nil { + return blameInfo, err + } + if err := cmd.Start(); err != nil { + return blameInfo, err + } + + scanner := bufio.NewScanner(bufio.NewReader(stdOut)) + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, pfxAuthorMail): + value := strings.TrimPrefix(line, pfxAuthorMail) + blameInfo.Mail = strings.Trim(value, "<>") + case strings.HasPrefix(line, pfxAuthorName): + blameInfo.Name = strings.TrimPrefix(line, pfxAuthorName) + case strings.HasPrefix(line, pfxTime): + value := strings.TrimPrefix(line, pfxTime) + unixTime, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return blameInfo, err + } + blameInfo.Time = time.Unix(unixTime, 0) + } + } + return blameInfo, nil +} diff --git a/todo/model.go b/todo/model.go new file mode 100644 index 0000000..fd144f6 --- /dev/null +++ b/todo/model.go @@ -0,0 +1,16 @@ +package todo + +import "time" + +type Todo struct { + Path string + Line int + Text string + BlameInfo +} + +type BlameInfo struct { + Name string + Mail string + Time time.Time +} diff --git a/todo/parse-go.go b/todo/parse-go.go new file mode 100644 index 0000000..19b19b2 --- /dev/null +++ b/todo/parse-go.go @@ -0,0 +1,43 @@ +package todo + +import ( + "go/parser" + "go/token" + "log" +) + +func ParseGo(path string) ([]Todo, error) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + return nil, err + } + + var todos []Todo + for _, c := range f.Comments { + for _, sc := range c.List { + isTodo := todoLineMatcher.MatchString(sc.Text) + if err != nil { + log.Fatal(err) + } + if isTodo { + pos := fset.Position(sc.Pos()) + todos = append(todos, Todo{ + Path: path, + Line: pos.Line, + Text: sc.Text, + }) + } + } + } + + for i := 0; i < len(todos); i++ { + blame, err := Blame(path, todos[i].Line) + if err != nil { + return nil, err + } + todos[i].BlameInfo = blame + } + + return todos, nil +} diff --git a/todo/regex.go b/todo/regex.go new file mode 100644 index 0000000..aef5ecc --- /dev/null +++ b/todo/regex.go @@ -0,0 +1,9 @@ +package todo + +import "regexp" + +//Match any line that contains the word t-o-d-o, case-insensitive + +const todoLineRegex = "(?i)\\btodo\\b" + +var todoLineMatcher = regexp.MustCompile(todoLineRegex) diff --git a/todo/regex_test.go b/todo/regex_test.go new file mode 100644 index 0000000..0fef417 --- /dev/null +++ b/todo/regex_test.go @@ -0,0 +1,29 @@ +package todo + +import ( + "regexp" + "testing" +) + +func TestMatch(t *testing.T) { + var isTodo = regexp.MustCompile(todoLineRegex) + + tests := []struct { + str string + wantMatch bool + }{ + {"todo: do stuff", true}, + {"TODO: do stuff", true}, + {"ToDo - do stuff", true}, + {"we should todo later", true}, + {"look at the rest of our todos", false}, + {"regular old normal comment line", false}, + } + + for _, tt := range tests { + gotMatch := isTodo.MatchString(tt.str) + if gotMatch != tt.wantMatch { + t.Errorf("\"%s\" wanted match %t got %t", tt.str, tt.wantMatch, gotMatch) + } + } +}