Skip to content

Commit

Permalink
Merge pull request #1 from mpittkin/first-version
Browse files Browse the repository at this point in the history
Initial version
  • Loading branch information
mpittkin authored Oct 25, 2023
2 parents f5bbf07 + a4f84a6 commit 26f76b0
Show file tree
Hide file tree
Showing 9 changed files with 377 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.idea
123 changes: 123 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
14 changes: 14 additions & 0 deletions output/console.go
Original file line number Diff line number Diff line change
@@ -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))
}
84 changes: 84 additions & 0 deletions output/slack.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions todo/git.go
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions todo/model.go
Original file line number Diff line number Diff line change
@@ -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
}
43 changes: 43 additions & 0 deletions todo/parse-go.go
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions todo/regex.go
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions todo/regex_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}

0 comments on commit 26f76b0

Please sign in to comment.