Repository: exercism/cli
Pull request #468 (2017-08-20 03:59:13)

[nextercism] Implement submit command
There are some unresolved corners in this, but I'd love to get a review of it, to see where I should go next.

kytrinyx: The build started failing, though it passed before. I'll figure out what changed.
kytrinyx: OK, I've rebased against the most recent `nextercism` and tweaked to take the changes there into account.
kytrinyx: I'm going to go ahead and merge this when the tests pass. I need to start getting the `nextercism` branch consolidated, as there's too much going on in too many places for me to figure out what to fix next.
kytrinyx: Oh. Wait. This won't work until we've gotten the `dep` thing worked out in  #478



Patch:
diff --git a/cmd/submit.go b/cmd/submit.go
index aa138fe..d6e3f64 100644
--- a/cmd/submit.go
+++ b/cmd/submit.go
@@ -1,9 +1,21 @@
 package cmd
 
 import (
+	"bytes"
+	"errors"
 	"fmt"
+	"io"
+	"mime/multipart"
+	"os"
+	"path/filepath"
+	"strings"
 
+	"github.com/exercism/cli/api"
+	"github.com/exercism/cli/comms"
+	"github.com/exercism/cli/config"
+	"github.com/exercism/cli/workspace"
 	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
 )
 
 // submitCmd lets people upload a solution to the website.
@@ -15,20 +27,154 @@ var submitCmd = &cobra.Command{
 
 The CLI will do its best to figure out what to submit.
 
-If you call the command without any arguments, it will check
-if the current directory is an exercise, and if so, submit that.
+If you call the command without any arguments, it will
+submit the exercise contained in the current directory.
 
 If called with the path to a directory, it will submit it.
 
 If called with the name of an exercise, it will work out which
 track it is on and submit it. The command will ask for help
 figuring things out if necessary.
-	`,
+`,
 	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println("submit called")
+		usrCfg, err := config.NewUserConfig()
+		BailOnError(err)
+
+		cliCfg, err := config.NewCLIConfig()
+		BailOnError(err)
+
+		if len(args) == 0 {
+			cwd, err := os.Getwd()
+			BailOnError(err)
+			args = []string{cwd}
+		}
+
+		// TODO: make sure we get the workspace configured.
+		if usrCfg.Workspace == "" {
+			cwd, err := os.Getwd()
+			BailOnError(err)
+			usrCfg.Workspace = filepath.Dir(filepath.Dir(cwd))
+		}
+
+		ws := workspace.New(usrCfg.Workspace)
+		tx, err := workspace.NewTransmission(ws.Dir, args)
+		BailOnError(err)
+
+		dirs, err := ws.Locate(tx.Dir)
+		BailOnError(err)
+
+		sx, err := workspace.NewSolutions(dirs)
+		BailOnError(err)
+
+		var solution *workspace.Solution
+
+		selection := comms.NewSelection()
+		for _, s := range sx {
+			selection.Items = append(selection.Items, s)
+		}
+
+		for {
+			prompt := `
+			We found more than one. Which one did you mean?
+			Type the number of the one you want to select.
+
+			%s
+			> `
+			option, err := selection.Pick(prompt)
+			if err != nil {
+				fmt.Println(err)
+				continue
+			}
+			s, ok := option.(*workspace.Solution)
+			if !ok {
+				fmt.Println("something went wrong trying to pick that solution, not sure what happened")
+				continue
+			}
+			solution = s
+			break
+		}
+
+		if !solution.IsRequester {
+			BailOnError(errors.New("not your solution"))
+		}
+		track := cliCfg.Tracks[solution.Track]
+		if track == nil {
+			err := prepareTrack(solution.Track)
+			BailOnError(err)
+			cliCfg.Load(viper.New())
+			track = cliCfg.Tracks[solution.Track]
+		}
+
+		paths := tx.Files
+		if len(paths) == 0 {
+			walkFn := func(path string, info os.FileInfo, err error) error {
+				if info.IsDir() {
+					return nil
+				}
+				ok, err := track.AcceptFilename(path)
+				BailOnError(err)
+				if !ok {
+					return nil
+				}
+				paths = append(paths, path)
+				return nil
+			}
+			filepath.Walk(solution.Dir, walkFn)
+		}
+
+		body := &bytes.Buffer{}
+		writer := multipart.NewWriter(body)
+
+		if len(paths) == 0 {
+			fmt.Fprintf(os.Stderr, "No files found to submit.")
+			os.Exit(1)
+		}
+
+		for _, path := range paths {
+			file, err := os.Open(path)
+			BailOnError(err)
+			defer file.Close()
+
+			filename := strings.Replace(path, filepath.Join(usrCfg.Workspace, solution.Track, solution.Exercise), "", -1)
+
+			dirname := fmt.Sprintf("%s%s%s", string(os.PathSeparator), solution.Exercise, string(os.PathSeparator))
+			pieces := strings.Split(path, dirname)
+			filename = fmt.Sprintf("%s%s", string(os.PathSeparator), pieces[len(pieces)-1])
+
+			part, err := writer.CreateFormFile("files[]", filename)
+			BailOnError(err)
+			_, err = io.Copy(part, file)
+			BailOnError(err)
+		}
+
+		err = writer.Close()
+		BailOnError(err)
+
+		apiCfg, err := config.NewAPIConfig()
+		BailOnError(err)
+
+		client, err := api.NewClient()
+		BailOnError(err)
+		req, err := client.NewRequest("PATCH", apiCfg.URL("submit", solution.ID), body)
+		BailOnError(err)
+		req.Header.Set("Content-Type", writer.FormDataContentType())
+
+		resp, err := client.Do(req, nil)
+		BailOnError(err)
+		defer resp.Body.Close()
+
+		bb := &bytes.Buffer{}
+		_, err = bb.ReadFrom(resp.Body)
+		BailOnError(err)
+
+		fmt.Fprintf(out, "Submitted. View at %s\n", solution.URL)
 	},
 }
 
+func initSubmitCmd() {
+	// todo
+}
+
 func init() {
 	RootCmd.AddCommand(submitCmd)
 }


Test patch:
diff --git a/cmd/submit_test.go b/cmd/submit_test.go
new file mode 100644
index 0000000..72c1786
--- /dev/null
+++ b/cmd/submit_test.go
@@ -0,0 +1,123 @@
+package cmd
+
+import (
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/exercism/cli/config"
+	"github.com/exercism/cli/workspace"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSubmit(t *testing.T) {
+	oldOut := out
+	out = ioutil.Discard
+	defer func() { out = oldOut }()
+
+	type file struct {
+		relativePath string
+		contents     string
+	}
+
+	file1 := file{
+		relativePath: "file-1.txt",
+		contents:     "This is file 1.",
+	}
+	file2 := file{
+		relativePath: filepath.Join("subdir", "file-2.txt"),
+		contents:     "This is file 2.",
+	}
+	file3 := file{
+		relativePath: "README.md",
+		contents:     "The readme.",
+	}
+
+	cmdTest := &CommandTest{
+		Cmd:    submitCmd,
+		InitFn: initSubmitCmd,
+		Args:   []string{"fakeapp", "submit", "bogus-exercise"},
+	}
+	cmdTest.Setup(t)
+	defer cmdTest.Teardown(t)
+
+	// Create a temp dir for the config and the exercise files.
+	dir := filepath.Join(cmdTest.TmpDir, "bogus-track", "bogus-exercise")
+	os.MkdirAll(filepath.Join(dir, "subdir"), os.FileMode(0755))
+
+	solution := &workspace.Solution{
+		ID:          "bogus-solution-uuid",
+		Track:       "bogus-track",
+		Exercise:    "bogus-exercise",
+		IsRequester: true,
+	}
+	err := solution.Write(dir)
+
+	for _, file := range []file{file1, file2, file3} {
+		err := ioutil.WriteFile(filepath.Join(dir, file.relativePath), []byte(file.contents), os.FileMode(0755))
+		assert.NoError(t, err)
+	}
+
+	// The fake endpoint will populate this when it receives the call from the command.
+	submittedFiles := map[string]string{}
+
+	// Set up the test server.
+	fakeEndpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		err := r.ParseMultipartForm(2 << 10)
+		if err != nil {
+			t.Fatal(err)
+		}
+		mf := r.MultipartForm
+
+		files := mf.File["files[]"]
+		for _, fileHeader := range files {
+			file, err := fileHeader.Open()
+			defer file.Close()
+
+			if err != nil {
+				t.Fatal(err)
+			}
+			body, err := ioutil.ReadAll(file)
+			if err != nil {
+				t.Fatal(err)
+			}
+			submittedFiles[fileHeader.Filename] = string(body)
+		}
+	})
+	ts := httptest.NewServer(fakeEndpoint)
+	defer ts.Close()
+
+	// Create a fake user config.
+	usrCfg := config.NewEmptyUserConfig()
+	usrCfg.Workspace = cmdTest.TmpDir
+	err = usrCfg.Write()
+	assert.NoError(t, err)
+
+	// Create a fake CLI config.
+	cliCfg, err := config.NewCLIConfig()
+	assert.NoError(t, err)
+	cliCfg.Tracks["bogus-track"] = config.NewTrack("bogus-track")
+	err = cliCfg.Write()
+	assert.NoError(t, err)
+
+	// Create a fake API config.
+	apiCfg, err := config.NewAPIConfig()
+	assert.NoError(t, err)
+	apiCfg.BaseURL = ts.URL
+	apiCfg.Endpoints["submit"] = "?%s"
+	err = apiCfg.Write()
+	assert.NoError(t, err)
+
+	// Execute the command!
+	cmdTest.App.Execute()
+
+	// We got only the file we expected.
+	assert.Equal(t, 2, len(submittedFiles))
+	for _, file := range []file{file1, file2} {
+		path := string(os.PathSeparator) + file.relativePath
+		assert.Equal(t, file.contents, submittedFiles[path])
+	}
+}