// This package defines several top level IO functions for interacting with // issues and collections of issues. // // Examples: // // To check if a folder is spec compliant: // ... // if issues.IsIssue(path_to_folder) { // process_issue(path_to_folder) // } // // Additionally, to check if a folder is a collection of spec compliant issues: // ... // if issues.IsIssueCollection(path_to_folder) { // process_collection(path_to_folder) // } // // To write an [Issue] object to disk: // ... // // NOT IMPLEMENTED // // To remove an [Issue] object from disk: // ... // // NOT IMPLEMENTED package issues import ( "errors" "fmt" "log" "math/rand" "os" "os/exec" "path/filepath" "slices" "strings" "time" "github.com/google/shlex" ) // converts file from []string to string, reports errors func readPath(path string) (output string, err error) { // TODO DEPRECATE content, err := os.ReadFile(path) if err != nil { return "", err } for _, line := range content { output = output + string(line) } return output, nil } // The quote string used to generate a template description type Template string var DescriptionTemplate = Template(`# Please provide a short, one line description. # Include any additional comments here. # ----------- # Note: Lines beginning with "#" are automatically ignored. # Note: It is recommended to leave a blank line after the short description.`) // generates template description files func GenerateTemplate(t Template, path string) error { err := os.WriteFile(path, []byte(DescriptionTemplate), 0755) return err } // parses template description files, removing any lines beginning with # // // Note: the function deletes the template file upon completion. func ReadTemplate(path string) (lines []string, err error) { data, err := os.ReadFile(path) if err != nil { return []string{}, err } sdata := string(data) slines := strings.SplitSeq(sdata, "\n") for line := range slines { line = line + "\n" if string(line[0]) != "#" { lines = append(lines, line) } } // strip trailing line if lines[len(lines)-1] == "\n" { lines = lines[:len(lines)-1] } return lines, err } // InvokeEditor invokes the system's configured editor on the specified path, // and reports any errors. // // InvokeEditor will attempt to determine the editor command in the following // order: // // 1. The $ISSUES_EDITOR environment variable // // 2. The $GIT_CONFIG environment variable // // 3. The $EDITOR environment variable // // Finally, if no configured editor is found, the program will exit with status // code 2 // // TODO(InvokeEditor) implement a channel & goroutine based concurrency lifecycle func InvokeEditor(path string) error { // determine editor // 1. Git config // 2. $EDITOR // 3. Panic? editor := os.Getenv("ISSUES_EDITOR") // if issue editor wasn't present, check git editor if editor == "" { editor = os.Getenv("GIT_EDITOR") } // if git editor wasn't present, check if editor set if editor == "" { editor = os.Getenv("EDITOR") } // if editor wasn't present, error out if editor == "" { log.Fatal("no editor set by system") os.Exit(2) } // execute editor editorCmd, err := shlex.Split(editor) if err != nil { log.Fatal("could not parse editor") os.Exit(2) } editorCmd = append(editorCmd, path) cmd := exec.Command(editorCmd[0], editorCmd[1:]...) cmd.Stdin = os.Stdin // pass stdin to cmd cmd.Stdout = os.Stdout // pass stdout to cmd cmd.Stderr = os.Stderr // pass stderr to cmd // wait for cmd and error out if err if err := cmd.Run(); err != nil { return fmt.Errorf("editor execution failed: %w", err) } return nil } // EditTemplate wraps GenerateTemplate and ReadTemplate, providing // both with a tempfile. EditTemplate then reports the content of the tempfile // after any modifications made by the editFunc callback and any encountered // errors. // // Note: A pre-built editor callback is provided. See InvokeEditor for more. func EditTemplate(t Template, editFunc func(path string) error) (data []string, err error) { // note: wraps writeText var seededRand = rand.New( rand.NewSource(time.Now().UnixNano())) makeID := func(length int) string { var charset = "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, length) for i := range b { b[i] = charset[seededRand.Intn(len(charset))] } return string(b) } // get get temp file tempfile, err := os.CreateTemp("", makeID(8)) if err != nil { return []string{}, nil } cleanup := func() { err = os.Remove(tempfile.Name()) if err != nil { panic(err) } } defer cleanup() err = GenerateTemplate(DescriptionTemplate, tempfile.Name()) if err != nil { return []string{}, nil } // invoke editor err = editFunc(tempfile.Name()) if err != nil { return []string{}, nil } // get data result, err := ReadTemplate(tempfile.Name()) return result, err } // Reports true when the specified path conforms to the minimum Poorman spec func IsIssue(path string) bool { files, err := os.ReadDir(path) if err != nil { return false } var specFiles []bool for _, file := range files { if file.Name() == "description" || file.Name() == "status" { specFiles = append(specFiles, true) } } if len(specFiles) >= 2 { return true } return false } // Reports true when the specified path is a directory of Issues func IsIssueCollection(path string) bool { if IsIssue(path) { return false } files, err := os.ReadDir(path) if err != nil { return false } var isIssue []bool for _, file := range files { if IsIssue(path + "/" + file.Name()) { isIssue = append(isIssue, true) } } if len(isIssue) > 0 { return true } return false } // Writes a issue to disk func WriteIssue(issue Issue, overwrite bool) (success bool, err error) { if IsIssue(issue.Path) && !overwrite { return false, errors.New("path exists") } // make base directory (effectively, the title) err = os.Mkdir(issue.Path, 0755) if err != nil && !overwrite { return false, err } // Write Fields f := []Field{issue.Description, issue.Status} for _, field := range f { if len(field.Path) > 0 { // skip empty paths path := filepath.Join(issue.Path, field.Path) err = os.WriteFile(path, []byte(field.Data), 0755) if err != nil { return false, err // test by overwrite path } } } // Write Tags if len(issue.Tags.Path) > 0 { // skip tags with no path init err = os.Mkdir(filepath.Join(issue.Path, issue.Tags.Path), 0755) if err != nil && !overwrite { return false, err } for _, field := range issue.Tags.Fields { if len(field.Path) > 0 { // skip empty paths path := filepath.Join(issue.Path, issue.Tags.Path, field.Path) err = os.WriteFile(path, []byte(field.Data), 0755) if err != nil { return false, err // test by overwrite path } } } } // Write Blockedby if len(issue.Blockedby.Path) > 0 { // skip blockedby with no path init err = os.Mkdir(filepath.Join(issue.Path, issue.Blockedby.Path), 0755) if err != nil && !overwrite { return false, err } for _, field := range issue.Blockedby.Fields { if len(field.Path) > 0 { // skip empty paths path := filepath.Join(issue.Path, issue.Blockedby.Path, field.Path) err = os.WriteFile(path, []byte(field.Data), 0755) if err != nil { return false, err // test by overwrite path } } } if err != nil && !overwrite { return false, err } } return true, nil } // Removes any fields from disk from a VariadicField that are not represented // in memory and reports the first error encountered. func (vf VariadicField) CleanDisk(issue Issue, ignoreData bool) error { dirContents, err := os.ReadDir(filepath.Join(issue.Path, vf.Path)) if err != nil { return err } var fieldsInMemory []string for _, field := range vf.Fields { fieldsInMemory = append(fieldsInMemory, field.Path) } for _, file := range dirContents { if !slices.Contains(fieldsInMemory, file.Name()) { // check if has data bytes, err := os.ReadFile(filepath.Join(issue.Path, vf.Path, file.Name())) if err != nil { return err } // if has data, and not ignore data if len(bytes) > 0 && ignoreData { return fmt.Errorf("%s has data, will not remove", filepath.Join(issue.Path, vf.Path, file.Name())) } // remove file err = os.Remove(filepath.Join(issue.Path, vf.Path, file.Name())) if err != nil { return err } } } return nil }