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