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}