Skip to content

Commit c24b8f4

Browse files
Add optional, org-wide Gitpod commit annotation (#20525)
* [supervisor] Add Gitpod commit annotation * server and API changes * [dashboard] add org setting for commit annotation * Fix things * Fix label for annotation switch * Revert accidental rename * minor docs fixes * Add a feature flag for the setting: `commit_annotation_setting_enabled` * Register hook in the cloned repo instead of under /etc/ * don't override existing hooks * `gp git-commit-message-helper` to use `git interpret-trailers` * Test it! * 🧹 indeed * Update timestamp of DB migration
1 parent ad4b7a8 commit c24b8f4

File tree

19 files changed

+1069
-557
lines changed

19 files changed

+1069
-557
lines changed

components/dashboard/src/data/featureflag-query.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const featureFlags = {
2525
enable_experimental_jbtb: false,
2626
enabled_configuration_prebuild_full_clone: false,
2727
enterprise_onboarding_enabled: false,
28+
commit_annotation_setting_enabled: false,
2829
};
2930

3031
type FeatureFlags = typeof featureFlags;

components/dashboard/src/data/organizations/update-org-settings-mutation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type UpdateOrganizationSettingsArgs = Partial<
2626
| "roleRestrictions"
2727
| "maxParallelRunningWorkspaces"
2828
| "onboardingSettings"
29+
| "annotateGitCommits"
2930
>
3031
>;
3132

@@ -47,6 +48,7 @@ export const useUpdateOrgSettingsMutation = () => {
4748
roleRestrictions,
4849
maxParallelRunningWorkspaces,
4950
onboardingSettings,
51+
annotateGitCommits,
5052
}) => {
5153
const settings = await organizationClient.updateOrganizationSettings({
5254
organizationId: teamId,
@@ -63,6 +65,7 @@ export const useUpdateOrgSettingsMutation = () => {
6365
updateRoleRestrictions: !!roleRestrictions,
6466
maxParallelRunningWorkspaces,
6567
onboardingSettings,
68+
annotateGitCommits,
6669
});
6770
return settings.settings!;
6871
},

components/dashboard/src/teams/TeamSettings.tsx

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,39 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7+
import { PlainMessage } from "@bufbuild/protobuf";
8+
import { EnvVar } from "@gitpod/gitpod-protocol";
9+
import { ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error";
710
import { OrganizationSettings } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
11+
import { Button } from "@podkit/buttons/Button";
12+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@podkit/select/Select";
13+
import { SwitchInputField } from "@podkit/switch/Switch";
14+
import { Heading2, Heading3, Subheading } from "@podkit/typography/Headings";
815
import React, { Children, ReactNode, useCallback, useMemo, useState } from "react";
916
import Alert from "../components/Alert";
1017
import ConfirmationModal from "../components/ConfirmationModal";
1118
import { InputWithCopy } from "../components/InputWithCopy";
1219
import Modal, { ModalBody, ModalFooter, ModalHeader } from "../components/Modal";
1320
import { InputField } from "../components/forms/InputField";
1421
import { TextInputField } from "../components/forms/TextInputField";
15-
import { Heading2, Heading3, Subheading } from "../components/typography/headings";
22+
import { useToast } from "../components/toasts/Toasts";
23+
import { useFeatureFlag } from "../data/featureflag-query";
24+
import { useInstallationDefaultWorkspaceImageQuery } from "../data/installation/default-workspace-image-query";
1625
import { useIsOwner } from "../data/organizations/members-query";
26+
import { useListOrganizationEnvironmentVariables } from "../data/organizations/org-envvar-queries";
1727
import { useOrgSettingsQuery } from "../data/organizations/org-settings-query";
1828
import { useCurrentOrg, useOrganizationsInvalidator } from "../data/organizations/orgs-query";
1929
import { useUpdateOrgMutation } from "../data/organizations/update-org-mutation";
2030
import { useUpdateOrgSettingsMutation } from "../data/organizations/update-org-settings-mutation";
31+
import { useDocumentTitle } from "../hooks/use-document-title";
2132
import { useOnBlurError } from "../hooks/use-onblur-error";
2233
import { ReactComponent as Stack } from "../icons/Stack.svg";
34+
import { ConfigurationSettingsField } from "../repositories/detail/ConfigurationSettingsField";
2335
import { organizationClient } from "../service/public-api";
2436
import { gitpodHostUrl } from "../service/service";
2537
import { useCurrentUser } from "../user-context";
2638
import { OrgSettingsPage } from "./OrgSettingsPage";
27-
import { ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error";
28-
import { Button } from "@podkit/buttons/Button";
29-
import { useInstallationDefaultWorkspaceImageQuery } from "../data/installation/default-workspace-image-query";
30-
import { ConfigurationSettingsField } from "../repositories/detail/ConfigurationSettingsField";
31-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@podkit/select/Select";
32-
import { useDocumentTitle } from "../hooks/use-document-title";
33-
import { PlainMessage } from "@bufbuild/protobuf";
34-
import { useToast } from "../components/toasts/Toasts";
3539
import { NamedOrganizationEnvvarItem } from "./variables/NamedOrganizationEnvvarItem";
36-
import { useListOrganizationEnvironmentVariables } from "../data/organizations/org-envvar-queries";
37-
import { EnvVar } from "@gitpod/gitpod-protocol";
3840

3941
export default function TeamSettingsPage() {
4042
useDocumentTitle("Organization Settings - General");
@@ -53,6 +55,7 @@ export default function TeamSettingsPage() {
5355
const gitpodImageAuthEnvVar = orgEnvVars.data?.find((v) => v.name === EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME);
5456

5557
const updateOrg = useUpdateOrgMutation();
58+
const isCommitAnnotationEnabled = useFeatureFlag("commit_annotation_setting_enabled");
5659

5760
const close = () => setModal(false);
5861

@@ -128,6 +131,17 @@ export default function TeamSettingsPage() {
128131
[updateTeamSettings, org?.id, isOwner, settings, toast],
129132
);
130133

134+
const handleUpdateAnnotatedCommits = useCallback(
135+
async (value: boolean) => {
136+
try {
137+
await handleUpdateTeamSettings({ annotateGitCommits: value });
138+
} catch (error) {
139+
console.error(error);
140+
}
141+
},
142+
[handleUpdateTeamSettings],
143+
);
144+
131145
return (
132146
<>
133147
<OrgSettingsPage>
@@ -213,6 +227,34 @@ export default function TeamSettingsPage() {
213227
/>
214228
</ConfigurationSettingsField>
215229

230+
{isCommitAnnotationEnabled && (
231+
<ConfigurationSettingsField>
232+
<Heading3>Insights</Heading3>
233+
<Subheading className="mb-4">
234+
Configure insights into usage of Gitpod in your organization.
235+
</Subheading>
236+
237+
<InputField
238+
label="Annotate git commits"
239+
hint={
240+
<>
241+
Add a <code>Tool:</code> field to all git commit messages created from
242+
workspaces in your organization to associate them with this Gitpod instance.
243+
</>
244+
}
245+
id="annotate-git-commits"
246+
>
247+
<SwitchInputField
248+
id="annotate-git-commits"
249+
checked={settings?.annotateGitCommits || false}
250+
disabled={!isOwner || isLoading}
251+
onCheckedChange={handleUpdateAnnotatedCommits}
252+
label=""
253+
/>
254+
</InputField>
255+
</ConfigurationSettingsField>
256+
)}
257+
216258
{showImageEditModal && (
217259
<OrgDefaultWorkspaceImageModal
218260
settings={settings}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) 2025 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License.AGPL.txt in the project root for license information.
4+
5+
package cmd
6+
7+
import (
8+
"context"
9+
"fmt"
10+
"os"
11+
"os/exec"
12+
"time"
13+
14+
"github.com/gitpod-io/gitpod/gitpod-cli/pkg/gitpod"
15+
log "github.com/sirupsen/logrus"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
var gitCommitMessageHelperOpts struct {
20+
CommitMessageFile string
21+
}
22+
23+
func addGitpodTrailer(commitMsgFile string, hostName string) error {
24+
trailerCmd := exec.Command("git", "interpret-trailers",
25+
"--if-exists", "addIfDifferent",
26+
"--trailer", fmt.Sprintf("Tool: gitpod/%s", hostName),
27+
commitMsgFile)
28+
29+
output, err := trailerCmd.Output()
30+
if err != nil {
31+
return fmt.Errorf("error adding trailer: %w", err)
32+
}
33+
34+
err = os.WriteFile(commitMsgFile, output, 0644)
35+
if err != nil {
36+
return fmt.Errorf("error writing commit message file: %w", err)
37+
}
38+
39+
return nil
40+
}
41+
42+
var gitCommitMessageHelper = &cobra.Command{
43+
Use: "git-commit-message-helper",
44+
Short: "Gitpod's Git commit message helper",
45+
Long: "Automatically adds Tool information to Git commit messages",
46+
Args: cobra.ExactArgs(0),
47+
Hidden: true,
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
50+
defer cancel()
51+
52+
wsInfo, err := gitpod.GetWSInfo(ctx)
53+
if err != nil {
54+
log.WithError(err).Fatal("error getting workspace info")
55+
return nil // don't block commit
56+
}
57+
58+
if err := addGitpodTrailer(gitCommitMessageHelperOpts.CommitMessageFile, wsInfo.GitpodApi.Host); err != nil {
59+
log.WithError(err).Fatal("failed to add gitpod trailer")
60+
return nil // don't block commit
61+
}
62+
63+
return nil
64+
},
65+
}
66+
67+
func init() {
68+
rootCmd.AddCommand(gitCommitMessageHelper)
69+
gitCommitMessageHelper.Flags().StringVarP(&gitCommitMessageHelperOpts.CommitMessageFile, "file", "f", "", "Path to the commit message file")
70+
_ = gitCommitMessageHelper.MarkFlagRequired("file")
71+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) 2025 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License.AGPL.txt in the project root for license information.
4+
5+
package cmd
6+
7+
import (
8+
"os"
9+
"testing"
10+
11+
"github.com/google/go-cmp/cmp"
12+
)
13+
14+
func TestAddGitpodTrailer(t *testing.T) {
15+
tests := []struct {
16+
Name string
17+
CommitMsg string
18+
HostName string
19+
Expected string
20+
ExpectError bool
21+
}{
22+
{
23+
Name: "adds trailer to simple message",
24+
CommitMsg: "Initial commit",
25+
HostName: "gitpod.io",
26+
Expected: "Initial commit\n\nTool: gitpod/gitpod.io\n",
27+
ExpectError: false,
28+
},
29+
{
30+
Name: "doesn't duplicate existing trailer",
31+
CommitMsg: "Initial commit\n\nTool: gitpod/gitpod.io\n",
32+
HostName: "gitpod.io",
33+
Expected: "Initial commit\n\nTool: gitpod/gitpod.io\n",
34+
ExpectError: false,
35+
},
36+
{
37+
Name: "preserves other trailers",
38+
CommitMsg: "Initial commit\n\nSigned-off-by: Kyle <[email protected]>\n",
39+
HostName: "gitpod.io",
40+
Expected: "Initial commit\n\nSigned-off-by: Kyle <[email protected]>\nTool: gitpod/gitpod.io\n",
41+
ExpectError: false,
42+
},
43+
}
44+
45+
for _, tt := range tests {
46+
t.Run(tt.Name, func(t *testing.T) {
47+
tmpfile, err := os.CreateTemp("", "commit-msg-*")
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
defer os.Remove(tmpfile.Name())
52+
53+
if err := os.WriteFile(tmpfile.Name(), []byte(tt.CommitMsg), 0644); err != nil {
54+
t.Fatal(err)
55+
}
56+
57+
err = addGitpodTrailer(tmpfile.Name(), tt.HostName)
58+
if (err != nil) != tt.ExpectError {
59+
t.Errorf("addGitpodTrailer() error = %v, wantErr %v", err, tt.ExpectError)
60+
return
61+
}
62+
63+
got, err := os.ReadFile(tmpfile.Name())
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
68+
equal := cmp.Equal(string(got), tt.Expected)
69+
if !equal {
70+
t.Fatalf(`Detected git command info was incorrect, got: %v, expected: %v.`, string(got), tt.Expected)
71+
}
72+
})
73+
}
74+
}

components/gitpod-db/src/typeorm/entity/db-team-settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ export class DBOrgSettings implements OrganizationSettings {
5151
@Column("json", { nullable: true })
5252
onboardingSettings?: OnboardingSettings | undefined;
5353

54+
@Column({ type: "boolean", default: false })
55+
annotateGitCommits?: boolean | undefined;
56+
5457
@Column()
5558
deleted: boolean;
5659
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { MigrationInterface, QueryRunner } from "typeorm";
8+
import { columnExists } from "./helper/helper";
9+
10+
const table = "d_b_org_settings";
11+
const newColumn = "annotateGitCommits";
12+
13+
export class AddOrgSettingsCommitAnnotation1737714449389 implements MigrationInterface {
14+
public async up(queryRunner: QueryRunner): Promise<void> {
15+
if (!(await columnExists(queryRunner, table, newColumn))) {
16+
await queryRunner.query(`ALTER TABLE ${table} ADD COLUMN ${newColumn} BOOLEAN DEFAULT FALSE`);
17+
}
18+
}
19+
20+
public async down(queryRunner: QueryRunner): Promise<void> {
21+
if (await columnExists(queryRunner, table, newColumn)) {
22+
await queryRunner.query(`ALTER TABLE ${table} DROP COLUMN ${newColumn}`);
23+
}
24+
}
25+
}

components/gitpod-db/src/typeorm/team-db-impl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ export class TeamDBImpl extends TransactionalDBImpl<TeamDB> implements TeamDB {
389389
"roleRestrictions",
390390
"maxParallelRunningWorkspaces",
391391
"onboardingSettings",
392+
"annotateGitCommits",
392393
],
393394
});
394395
}

components/gitpod-protocol/src/teams-projects-protocol.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,9 @@ export interface OrganizationSettings {
239239

240240
// onboarding settings for the organization
241241
onboardingSettings?: OnboardingSettings;
242+
243+
// whether to add a special annotation to commits that are created through Gitpod
244+
annotateGitCommits?: boolean;
242245
}
243246

244247
export type TimeoutSettings = {

components/public-api/gitpod/v1/organization.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ message OrganizationSettings {
6565
// max_parallel_running_workspaces is the maximum number of workspaces that a single user can run in parallel. 0 resets to the default, which depends on the org plan
6666
int32 max_parallel_running_workspaces = 9;
6767
OnboardingSettings onboarding_settings = 10;
68+
bool annotate_git_commits = 11;
6869
}
6970

7071
service OrganizationService {
@@ -193,6 +194,9 @@ message UpdateOrganizationSettingsRequest {
193194

194195
// onboarding_settings are the settings for the organization's onboarding
195196
optional OnboardingSettings onboarding_settings = 16;
197+
198+
// annotate_git_commits specifies whether to annotate git commits created in Gitpod workspaces with the gitpod host
199+
optional bool annotate_git_commits = 17;
196200
}
197201

198202
message UpdateOrganizationSettingsResponse {

0 commit comments

Comments
 (0)