2016-03-30 16:48:52 -05:00
|
|
|
// Copyright 2016 Tim Shannon. All rights reserved.
|
|
|
|
// Use of this source code is governed by the MIT license
|
|
|
|
// that can be found in the LICENSE file.
|
|
|
|
|
2016-03-29 16:43:58 -05:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2016-04-06 16:59:24 -05:00
|
|
|
"crypto/sha1"
|
2016-03-29 16:43:58 -05:00
|
|
|
"encoding/json"
|
2016-04-06 16:59:24 -05:00
|
|
|
"fmt"
|
2016-04-01 16:23:11 -05:00
|
|
|
"log"
|
2016-03-29 16:43:58 -05:00
|
|
|
"os"
|
|
|
|
"path/filepath"
|
2016-04-06 16:59:24 -05:00
|
|
|
"strings"
|
2016-03-30 16:48:52 -05:00
|
|
|
"sync"
|
|
|
|
"time"
|
2016-03-31 22:32:56 -05:00
|
|
|
|
|
|
|
"git.townsourced.com/ironsmith/datastore"
|
2016-03-29 16:43:58 -05:00
|
|
|
)
|
|
|
|
|
2016-04-01 14:30:17 -05:00
|
|
|
const (
|
|
|
|
enabledProjectDir = "enabled"
|
|
|
|
deletedProjectDir = "deleted"
|
|
|
|
)
|
2016-03-29 16:43:58 -05:00
|
|
|
|
2016-03-31 22:32:56 -05:00
|
|
|
//stages
|
|
|
|
const (
|
2016-04-17 20:43:27 -05:00
|
|
|
stageLoad = "loading"
|
|
|
|
stageFetch = "fetching"
|
|
|
|
stageBuild = "building"
|
|
|
|
stageTest = "testing"
|
|
|
|
stageRelease = "releasing"
|
|
|
|
stageReleased = "released"
|
|
|
|
stageWait = "waiting"
|
2016-03-31 22:32:56 -05:00
|
|
|
)
|
|
|
|
|
2016-04-01 16:23:11 -05:00
|
|
|
const projectFilePoll = 30 * time.Second
|
|
|
|
|
2016-03-29 16:43:58 -05:00
|
|
|
// Project is an ironsmith project that contains how to fetch, build, test, and release a project
|
2016-03-30 16:48:52 -05:00
|
|
|
/*
|
|
|
|
The project lifecycle goes like this, each step calling the next if successful
|
|
|
|
(Load Project file) -> (Fetch) -> (Build) -> (Test) -> (Release) - > (Sleep for polling period) ->
|
|
|
|
(Reload Project File) -> (Fetch) -> etc...
|
|
|
|
|
|
|
|
Changes the project file will be reloaded on every poll / trigger
|
|
|
|
If a project file is deleted then the cycle will finish it's current poll and stop at the load phase
|
|
|
|
*/
|
2016-03-29 16:43:58 -05:00
|
|
|
type Project struct {
|
|
|
|
Name string `json:"name"` // name of the project
|
|
|
|
|
|
|
|
Fetch string `json:"fetch"` //Script to fetch the latest project code into the current directory
|
|
|
|
Build string `json:"build"` //Script to build the latest project code
|
|
|
|
Test string `json:"test"` //Script to test the latest project code
|
|
|
|
Release string `json:"release"` //Script to build the release of latest project code
|
|
|
|
|
|
|
|
Version string `json:"version"` //Script to generate the version num of the current build, should be indempotent
|
|
|
|
|
|
|
|
ReleaseFile string `json:"releaseFile"`
|
2016-04-06 16:59:24 -05:00
|
|
|
PollInterval string `json:"pollInterval,omitempty"` // if not poll interval is specified, this project is trigger only
|
|
|
|
TriggerSecret string `json:"triggerSecret,omitempty"` //secret to be included with a trigger call
|
2016-03-30 16:48:52 -05:00
|
|
|
|
|
|
|
filename string
|
|
|
|
poll time.Duration
|
2016-03-31 22:32:56 -05:00
|
|
|
ds *datastore.Store
|
|
|
|
stage string
|
2016-04-13 15:53:49 -05:00
|
|
|
status string
|
2016-03-31 22:32:56 -05:00
|
|
|
version string
|
2016-04-05 16:59:52 -05:00
|
|
|
hash string
|
2016-04-06 16:59:24 -05:00
|
|
|
|
|
|
|
sync.RWMutex
|
2016-04-13 15:53:49 -05:00
|
|
|
processing sync.Mutex
|
2016-04-06 16:59:24 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) errHandled(err error) bool {
|
|
|
|
if err == nil {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
vlog("Error in project %s: %s\n", p.id(), err)
|
|
|
|
|
|
|
|
if p.ds == nil {
|
|
|
|
log.Printf("Error in project %s: %s\n", p.id(), err)
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
defer func() {
|
|
|
|
//clean up version folder if it exists
|
|
|
|
if p.version != "" {
|
|
|
|
err = os.RemoveAll(p.workingDir())
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("Error deleting the version directory project %s version %s: %s\n",
|
|
|
|
p.id(), p.version, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
lerr := p.ds.AddLog(p.version, p.stage, err.Error())
|
|
|
|
if lerr != nil {
|
|
|
|
log.Printf("Error logging an error in project %s: Original error %s, Logging Error: %s",
|
|
|
|
p.id(), err, lerr)
|
|
|
|
}
|
|
|
|
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
func projectID(filename string) string {
|
|
|
|
return strings.TrimSuffix(filename, filepath.Ext(filename))
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) id() string {
|
|
|
|
if p.filename == "" {
|
|
|
|
panic("invalid project filename")
|
|
|
|
}
|
|
|
|
return projectID(p.filename)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) dir() string {
|
|
|
|
return filepath.Join(dataDir, p.id())
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) workingDir() string {
|
|
|
|
if p.hash == "" {
|
|
|
|
panic(fmt.Sprintf("Working dir called with no version hash set for project %s", p.id()))
|
|
|
|
}
|
|
|
|
|
|
|
|
//It's probably overkill to use a sha1 hash to identify the build folder, when putting a simple
|
|
|
|
// timestamp on instead would work just fine, but I like having the working dir tied directly to the
|
|
|
|
// version returned by project script
|
|
|
|
|
|
|
|
return filepath.Join(p.dir(), p.hash)
|
|
|
|
}
|
|
|
|
|
|
|
|
// prepData makes sure the project's data folder and data store is created
|
|
|
|
/*
|
|
|
|
folder structure
|
|
|
|
projectDataFolder/<project-name>/<project-version>
|
|
|
|
|
|
|
|
*/
|
|
|
|
func (p *Project) open() error {
|
|
|
|
p.Lock()
|
|
|
|
defer p.Unlock()
|
|
|
|
|
|
|
|
if p.ds != nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2016-04-13 15:53:49 -05:00
|
|
|
err := os.MkdirAll(p.dir(), 0777)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2016-04-06 16:59:24 -05:00
|
|
|
ds, err := datastore.Open(filepath.Join(p.dir(), p.id()+".ironsmith"))
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
p.ds = ds
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) setVersion(version string) {
|
|
|
|
p.Lock()
|
|
|
|
defer p.Unlock()
|
|
|
|
|
|
|
|
p.version = version
|
|
|
|
if version == "" {
|
|
|
|
p.hash = ""
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
p.hash = fmt.Sprintf("%x", sha1.Sum([]byte(version)))
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) setStage(stage string) {
|
|
|
|
p.Lock()
|
|
|
|
defer p.Unlock()
|
|
|
|
|
|
|
|
if p.version != "" {
|
2016-04-13 15:53:49 -05:00
|
|
|
vlog("Entering %s stage for Project: %s Version: %s\n", stage, p.id(), p.version)
|
2016-04-06 16:59:24 -05:00
|
|
|
} else {
|
2016-04-13 15:53:49 -05:00
|
|
|
vlog("Entering %s stage for Project: %s\n", stage, p.id())
|
2016-04-06 16:59:24 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
p.stage = stage
|
|
|
|
}
|
|
|
|
|
|
|
|
type webProject struct {
|
2016-04-15 16:57:59 -05:00
|
|
|
ID string `json:"id"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
ReleaseVersion string `json:"releaseVersion"` //last successfully released version
|
|
|
|
Stage string `json:"stage"` // current stage
|
|
|
|
LastLog *datastore.Log `json:"lastLog"`
|
2016-04-06 16:59:24 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) webData() (*webProject, error) {
|
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
|
|
|
last, err := p.ds.LastVersion("")
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
release, err := p.ds.LastVersion(stageRelease)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
d := &webProject{
|
|
|
|
Name: p.Name,
|
|
|
|
ID: p.id(),
|
2016-04-14 11:29:56 -05:00
|
|
|
ReleaseVersion: release.Version,
|
2016-04-13 15:53:49 -05:00
|
|
|
Stage: p.stage,
|
2016-04-15 16:57:59 -05:00
|
|
|
LastLog: last,
|
2016-04-06 16:59:24 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
return d, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) versions() ([]*datastore.Log, error) {
|
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
|
|
|
return p.ds.Versions()
|
|
|
|
}
|
|
|
|
|
2016-04-07 09:49:54 -05:00
|
|
|
func (p *Project) versionLog(version string) ([]*datastore.Log, error) {
|
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
|
|
|
return p.ds.VersionLog(version)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) stageLog(version, stage string) (*datastore.Log, error) {
|
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
|
|
|
return p.ds.StageLog(version, stage)
|
|
|
|
}
|
|
|
|
|
2016-04-13 11:29:17 -05:00
|
|
|
func (p *Project) releases() ([]*datastore.Release, error) {
|
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
|
|
|
return p.ds.Releases()
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) lastRelease() (*datastore.Release, error) {
|
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
|
|
|
return p.ds.LastRelease()
|
|
|
|
}
|
|
|
|
func (p *Project) releaseData(version string) (*datastore.Release, error) {
|
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
|
|
|
return p.ds.Release(version)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) releaseFile(fileKey datastore.TimeKey) ([]byte, error) {
|
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
|
|
|
return p.ds.ReleaseFile(fileKey)
|
|
|
|
}
|
|
|
|
|
|
|
|
// releaseFile
|
|
|
|
|
2016-04-06 16:59:24 -05:00
|
|
|
func (p *Project) setData(new *Project) {
|
|
|
|
p.Lock()
|
|
|
|
defer p.Unlock()
|
|
|
|
|
|
|
|
p.Name = new.Name
|
|
|
|
|
|
|
|
p.Fetch = new.Fetch
|
|
|
|
p.Build = new.Build
|
|
|
|
p.Test = new.Test
|
|
|
|
p.Release = new.Release
|
|
|
|
p.Version = new.Version
|
|
|
|
|
|
|
|
p.ReleaseFile = new.ReleaseFile
|
|
|
|
p.PollInterval = new.PollInterval
|
|
|
|
p.TriggerSecret = new.TriggerSecret
|
|
|
|
|
|
|
|
if p.PollInterval != "" {
|
|
|
|
var err error
|
|
|
|
p.poll, err = time.ParseDuration(p.PollInterval)
|
|
|
|
if p.errHandled(err) {
|
|
|
|
p.poll = 0
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *Project) close() error {
|
|
|
|
p.Lock()
|
|
|
|
defer p.Unlock()
|
|
|
|
if p.ds == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
err := p.ds.Close()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
p.ds = nil
|
|
|
|
return nil
|
2016-03-29 16:43:58 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
const projectTemplateFilename = "template.project.json"
|
|
|
|
|
|
|
|
var projectTemplate = &Project{
|
|
|
|
Name: "Template Project",
|
|
|
|
Fetch: "git clone root@git.townsourced.com:tshannon/ironsmith.git .",
|
2016-04-13 15:53:49 -05:00
|
|
|
Build: "go build -a -v -o ironsmith",
|
2016-04-05 16:59:52 -05:00
|
|
|
Test: "go test ./...",
|
|
|
|
Release: "tar -czf release.tar.gz ironsmith",
|
2016-03-29 16:43:58 -05:00
|
|
|
Version: "git describe --tags --long",
|
|
|
|
|
2016-04-05 16:59:52 -05:00
|
|
|
ReleaseFile: "release.tar.gz",
|
2016-03-29 16:43:58 -05:00
|
|
|
PollInterval: "15m",
|
|
|
|
}
|
|
|
|
|
|
|
|
func prepTemplateProject() error {
|
|
|
|
filename := filepath.Join(projectDir, projectTemplateFilename)
|
|
|
|
_, err := os.Stat(filename)
|
|
|
|
if os.IsNotExist(err) {
|
2016-04-05 16:59:52 -05:00
|
|
|
vlog("Creating template project file in %s", filename)
|
2016-03-29 16:43:58 -05:00
|
|
|
f, err := os.Create(filename)
|
|
|
|
defer func() {
|
|
|
|
if cerr := f.Close(); cerr != nil && err == nil {
|
|
|
|
err = cerr
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
data, err := json.MarshalIndent(projectTemplate, "", " ")
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
_, err = f.Write(data)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
} else if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
2016-03-30 16:48:52 -05:00
|
|
|
|
|
|
|
type projectList struct {
|
|
|
|
sync.RWMutex
|
|
|
|
data map[string]*Project
|
|
|
|
}
|
|
|
|
|
|
|
|
var projects = projectList{
|
|
|
|
data: make(map[string]*Project),
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *projectList) load() error {
|
2016-04-05 16:59:52 -05:00
|
|
|
vlog("Loading projects from the enabled definitions in %s\n", filepath.Join(projectDir, enabledProjectDir))
|
2016-03-30 16:48:52 -05:00
|
|
|
dir, err := os.Open(filepath.Join(projectDir, enabledProjectDir))
|
|
|
|
defer func() {
|
|
|
|
if cerr := dir.Close(); cerr != nil && err == nil {
|
|
|
|
err = cerr
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
files, err := dir.Readdir(0)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
for i := range files {
|
|
|
|
if !files[i].IsDir() && filepath.Ext(files[i].Name()) == ".json" {
|
2016-04-01 16:23:11 -05:00
|
|
|
p.add(files[i].Name())
|
2016-03-30 16:48:52 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-04-01 16:23:11 -05:00
|
|
|
time.AfterFunc(projectFilePoll, startProjectLoader)
|
2016-03-30 16:48:52 -05:00
|
|
|
|
2016-04-01 16:23:11 -05:00
|
|
|
return nil
|
2016-03-31 22:32:56 -05:00
|
|
|
}
|
|
|
|
|
2016-04-06 16:59:24 -05:00
|
|
|
func (p *projectList) get(name string) (*Project, bool) {
|
2016-03-30 16:48:52 -05:00
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
2016-04-06 16:59:24 -05:00
|
|
|
prj, ok := p.data[name]
|
|
|
|
return prj, ok
|
2016-03-30 16:48:52 -05:00
|
|
|
}
|
|
|
|
|
2016-04-01 16:23:11 -05:00
|
|
|
func (p *projectList) add(name string) {
|
2016-04-05 16:59:52 -05:00
|
|
|
vlog("Adding project %s to the project list.\n", name)
|
2016-04-01 16:23:11 -05:00
|
|
|
p.Lock()
|
|
|
|
defer p.Unlock()
|
|
|
|
|
|
|
|
prj := &Project{
|
|
|
|
filename: name,
|
|
|
|
Name: name,
|
|
|
|
stage: stageLoad,
|
|
|
|
}
|
2016-04-06 16:59:24 -05:00
|
|
|
p.data[projectID(name)] = prj
|
2016-04-01 16:23:11 -05:00
|
|
|
|
|
|
|
go func() {
|
2016-04-06 16:59:24 -05:00
|
|
|
err := prj.open()
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("Error opening datastore for Project: %s Error: %s\n", prj.id(), err)
|
|
|
|
return
|
|
|
|
}
|
2016-04-01 16:23:11 -05:00
|
|
|
prj.load()
|
|
|
|
}()
|
|
|
|
}
|
|
|
|
|
|
|
|
// removeMissing removes projects that are missing from the passed in list of names
|
|
|
|
func (p *projectList) removeMissing(names []string) {
|
|
|
|
p.Lock()
|
|
|
|
defer p.Unlock()
|
|
|
|
|
|
|
|
for i := range p.data {
|
|
|
|
found := false
|
|
|
|
for k := range names {
|
2016-04-06 16:59:24 -05:00
|
|
|
if projectID(names[k]) == i {
|
2016-04-01 16:23:11 -05:00
|
|
|
found = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !found {
|
2016-04-05 16:59:52 -05:00
|
|
|
vlog("Removing project %s from the project list, because the project file was removed.\n",
|
|
|
|
i)
|
2016-04-01 16:23:11 -05:00
|
|
|
delete(p.data, i)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-04-06 16:59:24 -05:00
|
|
|
func (p *projectList) stopAll() {
|
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
|
|
|
for i := range p.data {
|
|
|
|
err := p.data[i].close()
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("Error closing project datastore for Project: %s Error: %s\n", p.data[i].id(), err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (p *projectList) webList() ([]*webProject, error) {
|
2016-04-05 16:59:52 -05:00
|
|
|
p.RLock()
|
|
|
|
defer p.RUnlock()
|
|
|
|
|
2016-04-06 16:59:24 -05:00
|
|
|
list := make([]*webProject, 0, len(p.data))
|
|
|
|
|
2016-04-05 16:59:52 -05:00
|
|
|
for i := range p.data {
|
2016-04-06 16:59:24 -05:00
|
|
|
prj, err := p.data[i].webData()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2016-04-06 11:31:22 -05:00
|
|
|
}
|
2016-04-06 16:59:24 -05:00
|
|
|
|
|
|
|
list = append(list, prj)
|
2016-04-05 16:59:52 -05:00
|
|
|
}
|
2016-04-06 16:59:24 -05:00
|
|
|
|
|
|
|
return list, nil
|
2016-04-05 16:59:52 -05:00
|
|
|
}
|
|
|
|
|
2016-03-30 16:48:52 -05:00
|
|
|
// startProjectLoader polls for new projects
|
|
|
|
func startProjectLoader() {
|
2016-04-01 16:23:11 -05:00
|
|
|
dir, err := os.Open(filepath.Join(projectDir, enabledProjectDir))
|
|
|
|
defer func() {
|
|
|
|
if cerr := dir.Close(); cerr != nil && err == nil {
|
|
|
|
err = cerr
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
if err != nil {
|
2016-04-05 16:59:52 -05:00
|
|
|
log.Printf("Error in startProjectLoader opening the filepath %s: %s\n", projectDir, err)
|
2016-04-01 16:23:11 -05:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
files, err := dir.Readdir(0)
|
|
|
|
if err != nil {
|
2016-04-05 16:59:52 -05:00
|
|
|
log.Printf("Error in startProjectLoader reading the dir %s: %s\n", projectDir, err)
|
2016-04-01 16:23:11 -05:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
names := make([]string, len(files))
|
|
|
|
|
|
|
|
for i := range files {
|
|
|
|
if !files[i].IsDir() && filepath.Ext(files[i].Name()) == ".json" {
|
|
|
|
names[i] = files[i].Name()
|
2016-04-06 16:59:24 -05:00
|
|
|
if _, ok := projects.get(projectID(files[i].Name())); !ok {
|
2016-04-01 16:23:11 -05:00
|
|
|
projects.add(files[i].Name())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
//check for removed projects
|
|
|
|
projects.removeMissing(names)
|
2016-03-30 16:48:52 -05:00
|
|
|
|
2016-04-01 16:23:11 -05:00
|
|
|
time.AfterFunc(projectFilePoll, startProjectLoader)
|
2016-03-30 16:48:52 -05:00
|
|
|
}
|