GitRoot

craft your forge, build your project, grow your community freely
  1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
  2//
  3// SPDX-License-Identifier: EUPL-1.2
  4
  5package main
  6
  7// compile cmd
  8// tinygo build -o grafter-0.0.1.wasm -scheduler=none --no-debug -target=wasi ./
  9
 10import (
 11	"bytes"
 12	_ "embed"
 13	"fmt"
 14	"io"
 15	"os"
 16	"path/filepath"
 17	"regexp"
 18	"strings"
 19
 20	"github.com/tidwall/gjson"
 21	gitroot "gitroot.dev/libs/golang/plugin"
 22)
 23
 24type Plugin struct {
 25	server   gitroot.Server
 26	conf     pluginConf
 27	newGraft *graft
 28}
 29
 30type pluginConf struct {
 31	defaultTargetBranch string
 32}
 33
 34const (
 35	statusNew    = ""
 36	statusDraft  = "draft"
 37	statusReview = "review"
 38	statusMerge  = "merge"
 39)
 40
 41type graft struct {
 42	status       string
 43	filename     string
 44	branch       string
 45	commitsLines []string
 46	pushContent  []string
 47	content      string
 48	currentHash  string
 49}
 50
 51var defaultRun = []gitroot.PluginRun{{
 52	Path:   "**/*",
 53	Branch: []string{"*"},
 54	When:   gitroot.PluginRunWhenAll,
 55	Write: gitroot.PluginWrite{
 56		Git: []gitroot.PluginWriteRight{{
 57			Path: "**/*",
 58			Can:  gitroot.PluginWriteRightCanAll,
 59		}},
 60		Web: []gitroot.PluginWriteRight{},
 61	},
 62	Configuration: map[string]any{"defaultTargetBranch": "main"},
 63}}
 64
 65var defaultGraft = `---
 66target: %s
 67status: draft
 68---
 69
 70# Want to merge %s
 71
 72Please add description of your work here.
 73
 74When your changes are ready to be reviewed, change the status from 'status: draft' to 'status: review'.
 75`
 76
 77var defaultReviewMessage = `
 78---
 79
 80Please review changes with:
 81
 82` + strings.Repeat("`", 3) + `shell
 83git fetch origin %s
 84git checkout %s
 85git diff origin/HEAD..HEAD
 86` + strings.Repeat("`", 3) + `
 87
 88Make your changes about the code inside the code directly (remarks, questions can be made in the form of comments). General remarks on the why can be made here. When done commit and push directly on this branch. Graft plugin will make the diff and display it here.
 89
 90When then changes are ready to be merged, change the status from 'status: review' to 'status: merge'.
 91`
 92
 93var targetRegexp = regexp.MustCompile(`- target:\s(.*)`)
 94
 95func (p *Plugin) Init(repoName string, confHasChanged bool, serializedConf string) error {
 96	defaultTargetBranch := gjson.Parse(serializedConf).Get("defaultTargetBranch").String()
 97	if defaultTargetBranch == "" {
 98		defaultTargetBranch = "main"
 99	}
100	p.conf = pluginConf{
101		defaultTargetBranch: defaultTargetBranch,
102	}
103	p.newGraft = nil
104	return nil
105}
106
107func (p *Plugin) StartCommit(commit gitroot.Commit) error {
108	//does nothing if user push on main
109	if commit.Branch == p.conf.defaultTargetBranch {
110		return nil
111	}
112	commitLine := fmt.Sprintf("### %s (%s)\n", commit.Message, commit.Hash)
113	if p.newGraft == nil {
114		graftFilepath := fmt.Sprintf("grafts/%s.md", commit.Branch)
115		file, err := p.server.Worktree().Open(graftFilepath)
116		if err != nil && !os.IsNotExist(err) {
117			return err
118		}
119		if file == nil {
120			p.newGraft = &graft{
121				status:       statusNew,
122				filename:     graftFilepath,
123				branch:       commit.Branch,
124				commitsLines: []string{commitLine},
125				content:      fmt.Sprintf(defaultGraft, p.conf.defaultTargetBranch, commit.Branch),
126			}
127		} else {
128			filecontent, err := io.ReadAll(file)
129			if err != nil {
130				return err
131			}
132			metas, needMeta := p.parseMetas(filecontent)
133			if needMeta {
134				if bytes.HasPrefix(filecontent, []byte("---\n")) {
135					filecontent = bytes.TrimPrefix(filecontent, []byte("---\n"))
136					filecontent = filecontent[bytes.Index(filecontent, []byte("---\n"))+4:]
137				}
138				filecontent = append(p.serializeMetas(metas), bytes.TrimPrefix(filecontent, []byte("\n"))...)
139			}
140			p.newGraft = &graft{
141				status:       metas["status"],
142				filename:     graftFilepath,
143				branch:       commit.Branch,
144				commitsLines: []string{commitLine},
145				content:      string(filecontent),
146			}
147
148			formattedReviewMessage := fmt.Sprintf(defaultReviewMessage, commit.Branch, commit.Branch)
149			if metas["status"] == statusReview && !strings.Contains(p.newGraft.content, formattedReviewMessage) {
150				p.newGraft.content = fmt.Sprintf("%s%s", p.newGraft.content, formattedReviewMessage)
151			}
152		}
153	} else {
154		p.newGraft.commitsLines = append(p.newGraft.commitsLines, commitLine)
155	}
156	p.newGraft.pushContent = []string{}
157	p.newGraft.currentHash = commit.Hash
158	return nil
159}
160
161func (p *Plugin) serializeMetas(metas map[string]string) []byte {
162	m := strings.Builder{}
163	m.WriteString("---\n")
164	if target, ok := metas["target"]; ok {
165		m.WriteString(fmt.Sprintf("target: %s\n", target))
166	}
167	if status, ok := metas["status"]; ok {
168		m.WriteString(fmt.Sprintf("status: %s\n", status))
169	}
170	if reviewers, ok := metas["reviewers"]; ok {
171		m.WriteString(fmt.Sprintf("reviewers: %s\n", reviewers))
172	}
173	m.WriteString("---\n\n")
174	return []byte(m.String())
175}
176
177func (p *Plugin) parseMetas(filecontent []byte) (map[string]string, bool) {
178	metas := map[string]string{}
179	fileSplited := strings.SplitN(string(filecontent), "---", 3)
180	needMeta := false
181	if len(fileSplited) > 2 {
182		metasStr := strings.Split(fileSplited[1], "\n")
183		for _, m := range metasStr {
184			keyVal := strings.Split(m, ":")
185			if len(keyVal) > 1 {
186				metas[strings.TrimSpace(keyVal[0])] = strings.TrimSpace(keyVal[1])
187			}
188		}
189	}
190	if _, ok := metas["status"]; !ok {
191		needMeta = true
192		metas["status"] = statusDraft
193	}
194	if _, ok := metas["target"]; !ok {
195		needMeta = true
196		metas["target"] = p.conf.defaultTargetBranch
197	}
198	if _, ok := metas["reviewers"]; !ok && metas["status"] == statusReview {
199		needMeta = true
200		metas["reviewers"] = "[]"
201	}
202	return metas, needMeta
203}
204
205func (p *Plugin) AddFile(path string) error {
206	//does nothing if user push on main
207	if p.newGraft == nil {
208		return nil
209	}
210	if path != p.newGraft.filename {
211		from, _ := filepath.Abs(path)
212		relPath, _ := filepath.Rel("/grafts/", from)
213		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- ++ [%s](%s)", path, filepath.Clean(relPath)))
214	}
215	return nil
216}
217
218func (p *Plugin) ModFile(fromPath string, toPath string) error {
219	//does nothing if user push on main
220	if p.newGraft == nil {
221		return nil
222	}
223
224	if toPath != p.newGraft.filename {
225		from, _ := filepath.Abs(toPath)
226		relPath, _ := filepath.Rel("/grafts/", from)
227		if fromPath != toPath {
228			p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- %s -> [%s](%s)", fromPath, toPath, filepath.Clean(relPath)))
229		}
230		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- +- [%s](%s)", toPath, filepath.Clean(relPath)))
231		if p.newGraft.status == statusReview {
232			if diff, err := p.server.DiffWithParent(p.newGraft.currentHash, fromPath, toPath); err == nil {
233				p.newGraft.pushContent = append(p.newGraft.pushContent, diff)
234			} else {
235				p.server.LogError("can't get diff", err)
236			}
237		}
238	}
239	return nil
240}
241
242func (p *Plugin) DelFile(path string) error {
243	//does nothing if user push on main
244	if p.newGraft == nil {
245		return nil
246	}
247	if path != p.newGraft.filename {
248		from, _ := filepath.Abs(path)
249		relPath, _ := filepath.Rel("/grafts/", from)
250		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- -- [%s](%s)", path, filepath.Clean(relPath)))
251	}
252	return nil
253}
254
255func (p *Plugin) EndCommit(commit gitroot.Commit) error {
256	//does nothing if user push on main
257	if p.newGraft == nil {
258		return nil
259	}
260	if len(p.newGraft.pushContent) > 0 {
261		p.newGraft.commitsLines[len(p.newGraft.commitsLines)-1] = fmt.Sprintf("%s\n%s\n", p.newGraft.commitsLines[len(p.newGraft.commitsLines)-1], strings.Join(p.newGraft.pushContent, "\n"))
262	} else {
263		// a commit which touch others files or only graft file is ignored
264		p.newGraft.commitsLines = p.newGraft.commitsLines[:len(p.newGraft.commitsLines)-1]
265	}
266	return nil
267}
268
269func (p *Plugin) Finish() error {
270	// does nothing if user push on main
271	if p.newGraft == nil {
272		return nil
273	}
274	content := p.newGraft.content
275	if len(p.newGraft.commitsLines) > 0 {
276		commit := "commit"
277		if len(p.newGraft.commitsLines) > 1 {
278			commit = "commits"
279		}
280		content = fmt.Sprintf("%s\n---\n\n## Push %d %s\n\n%s", p.newGraft.content, len(p.newGraft.commitsLines), commit, strings.Join(p.newGraft.commitsLines, "\n"))
281	}
282	p.server.ModifyContent(p.newGraft.filename, content)
283	message := "graft updated"
284	if p.newGraft.status == statusNew {
285		message = "graft created"
286	}
287	p.server.CommitAllIfNeeded(message)
288	if p.newGraft.status == statusMerge {
289		all := targetRegexp.FindStringSubmatch(p.newGraft.content)
290		if len(all) > 1 {
291			p.server.Merge(all[1], p.newGraft.branch)
292		} else {
293			p.server.Merge(p.conf.defaultTargetBranch, p.newGraft.branch)
294		}
295	}
296	return nil
297}
298
299func Build(server gitroot.Server) gitroot.Plugin {
300	return &Plugin{
301		server: server,
302	}
303}
304
305//go:wasmexport install
306func main() {
307	gitroot.Register(defaultRun, Build)
308}