diff --git a/actions/review.go b/actions/review.go index 7b08318..d8610ed 100644 --- a/actions/review.go +++ b/actions/review.go @@ -59,9 +59,13 @@ func Review(c *cli.Context) error { Transport: &authTransport{Token: token}, }) - if err := checkCleanWorktree(ctx, gitHubRepo); err != nil { + isClean, err := isCleanWorktree(ctx, gitHubRepo) + if err != nil { return err } + if !isClean { + return errors.Errorf("index is not clean") + } // Validate reviewer usernames. reviewers := c.StringSlice("reviewer") @@ -135,7 +139,7 @@ func Review(c *cli.Context) error { return nil } -func checkCleanWorktree(ctx context.Context, gitHubRepo *gitHubRepo) error { +func isCleanWorktree(ctx context.Context, gitHubRepo *gitHubRepo) (bool, error) { // Worktree.Status() is very slow so fall back to the command line instead. // https://github.com/go-git/go-git/issues/181 cmd := exec.Command("git", "status", "--porcelain") @@ -145,25 +149,21 @@ func checkCleanWorktree(ctx context.Context, gitHubRepo *gitHubRepo) error { if err != nil { var exitErr exec.ExitError if errors.As(err, &exitErr) { - return errors.Errorf("git status failed: %s", exitErr.Stderr) + return false, errors.Errorf("git status failed: %s", exitErr.Stderr) } - return errors.WithStack(err) - } - if stdout.Len() > 0 { - return errors.Errorf("index is not clean") + return false, errors.WithStack(err) } - return nil + return stdout.Len() == 0, nil } -func getReviewInfo( +// getCommitRange determine the common ancestor between headHash and the default +// branch, and returns the base and head commits. +func getCommitRange( ctx context.Context, gitHubRepo *gitHubRepo, - graphqlClient *graphql.Client, headHash plumbing.Hash, -) ([]*reviewInfo, error) { +) ([]*object.Commit, error) { deps := deps.FromContext(ctx) - - // Determine the common ancestor between the current branch and master repo := gitHubRepo.GitRepo() defaultBranchCommit, err := repo.CommitObject( gitHubRepo.DefaultBranchRef().Hash(), @@ -187,11 +187,36 @@ func getReviewInfo( return nil, errors.New("no new commits") } deps.DebugLog.Println("found base commit at", baseCommit.Hash) + var result []*object.Commit + commit := headCommit + for commit.Hash != baseCommit.Hash { + result = append(result, commit) + if len(commit.ParentHashes) != 1 { + return nil, errors.Errorf("commit %v has multiple hashes", commit.Hash) + } + commit, err = repo.CommitObject(commit.ParentHashes[0]) + if err != nil { + return nil, errors.WithStack(err) + } + } + return result, nil +} + +func getReviewInfo( + ctx context.Context, + gitHubRepo *gitHubRepo, + graphqlClient *graphql.Client, + headHash plumbing.Hash, +) ([]*reviewInfo, error) { + deps := deps.FromContext(ctx) + commitRange, err := getCommitRange(ctx, gitHubRepo, headHash) + if err != nil { + return nil, err + } // Collect sequence of commits of interest - commit := headCommit var ris []*reviewInfo - for commit.Hash != baseCommit.Hash { + for _, commit := range commitRange { ri, err := makeReviewInfo(ctx, gitHubRepo, graphqlClient, commit) if err != nil { return nil, err @@ -203,14 +228,6 @@ func getReviewInfo( status = "reviewID: " + ri.reviewID + " pr: " + ri.pr.GetHTMLURL() } deps.DebugLog.Println("examined", commit.Hash, status) - - if len(commit.ParentHashes) != 1 { - return nil, errors.Errorf("commit %v has multiple hashes", commit.Hash) - } - commit, err = repo.CommitObject(commit.ParentHashes[0]) - if err != nil { - return nil, errors.WithStack(err) - } } // Reverse the array so the tip commit is at the end for i, j := 0, len(ris)-1; i < j; i, j = i+1, j-1 { @@ -449,7 +466,7 @@ func printReviewInfo(ctx context.Context, ris []*reviewInfo) { if ri.updatedCommit != nil { commit = ri.updatedCommit } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", commit.Hash.String()[:8], title, reviewURL, status) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", commit.Hash.String()[:8], title, status, reviewURL) } w.Flush() } diff --git a/actions/status.go b/actions/status.go new file mode 100644 index 0000000..0dce67d --- /dev/null +++ b/actions/status.go @@ -0,0 +1,115 @@ +package actions + +import ( + "fmt" + "io" + "net/http" + "strings" + "text/tabwriter" + + "github.com/bitcomplete/plz-cli/client/deps" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/pkg/errors" + "github.com/shurcooL/graphql" + "github.com/urfave/cli/v2" +) + +type statusReview struct { + ID string `graphql:"id"` + RevisionList struct { + Revisions []struct { + HeadCommitSHA string `graphql:"headCommitSha"` + } `graphql:"revisions"` + } `graphql:"revisionList"` +} + +func Status(c *cli.Context) error { + ctx := c.Context + deps := deps.FromContext(ctx) + + token, err := deps.Auth.Token() + if err != nil { + return err + } + gitHubRepo, err := newGitHubRepo(ctx, token) + if err != nil { + return err + } + graphqlClient := graphql.NewClient(deps.PlzAPIBaseURL+"/api/v1", &http.Client{ + Transport: &authTransport{Token: token}, + }) + + headRef, err := gitHubRepo.GitRepo().Head() + if err != nil { + return errors.WithStack(err) + } + deps.DebugLog.Println("HEAD is at", headRef.Hash()) + + commitRange, err := getCommitRange(ctx, gitHubRepo, headRef.Hash()) + if err != nil { + return err + } + if len(commitRange) == 0 { + return nil + } + + isClean, err := isCleanWorktree(ctx, gitHubRepo) + if err != nil { + return err + } + if !isClean { + deps.InfoLog.Println("unstaged changes") + } + + w := tabwriter.NewWriter(deps.InfoLog.Writer(), 0, 0, 1, ' ', 0) + for _, commit := range commitRange { + var review *statusReview + if reviewID := readReviewIDFromCommitMessage(commit.Message); reviewID != "" { + deps.DebugLog.Println("loading review", reviewID) + var query struct { + Review statusReview `graphql:"review(id: $reviewID)"` + } + err := graphqlClient.Query(ctx, &query, map[string]interface{}{ + "reviewID": graphql.ID(reviewID), + }) + if err != nil { + return errors.WithStack(err) + } + review = &query.Review + } + printReviewStatus(w, commit, review) + } + w.Flush() + return nil +} + +func printReviewStatus(w io.Writer, commit *object.Commit, review *statusReview) { + status := "new" + if review != nil { + revisionIndex := -1 + for i, revision := range review.RevisionList.Revisions { + if revision.HeadCommitSHA == commit.Hash.String() { + revisionIndex = i + break + } + } + switch revisionIndex { + case -1: + status = "modified" + case 0: + status = "current" + default: + status = "outdated" + } + } + parts := strings.SplitN(commit.Message, "\n", 2) + title := strings.TrimSpace(parts[0]) + if len(title) > 47 { + title = title[:47] + "..." + } + reviewURL := "" + if review != nil { + reviewURL = "https://plz.review/review/" + review.ID + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", commit.Hash.String()[:8], title, status, reviewURL) +} diff --git a/actions/switch.go b/actions/switch.go index 7f18959..71c0832 100644 --- a/actions/switch.go +++ b/actions/switch.go @@ -59,9 +59,15 @@ func Switch(c *cli.Context) error { if err != nil { return err } - if err := checkCleanWorktree(ctx, gitHubRepo); err != nil { + + isClean, err := isCleanWorktree(ctx, gitHubRepo) + if err != nil { return err } + if !isClean { + return errors.Errorf("index is not clean") + } + repo := gitHubRepo.GitRepo() headRef, err := repo.Head() if err != nil { diff --git a/actions/sync.go b/actions/sync.go index 0299702..6881775 100644 --- a/actions/sync.go +++ b/actions/sync.go @@ -71,9 +71,13 @@ func Sync(c *cli.Context) error { Transport: &authTransport{Token: token}, }) - if err := checkCleanWorktree(ctx, gitHubRepo); err != nil { + isClean, err := isCleanWorktree(ctx, gitHubRepo) + if err != nil { return err } + if !isClean { + return errors.Errorf("index is not clean") + } headRef, err := gitHubRepo.GitRepo().Head() if err != nil { diff --git a/cmd/plz/main.go b/cmd/plz/main.go index d223492..2446869 100644 --- a/cmd/plz/main.go +++ b/cmd/plz/main.go @@ -46,6 +46,11 @@ func main() { Usage: "update local review branches", Action: actions.Sync, }, + { + Name: "status", + Usage: "list local review status", + Action: actions.Status, + }, }, Flags: []cli.Flag{ &cli.BoolFlag{