Skip to content

Commit 2a0062f

Browse files
feat(api): add microsoft sign-in and outlook mailbox sync (#73)
Co-authored-by: Lewis Carhart <lewis@trycomp.ai>
1 parent 477e592 commit 2a0062f

90 files changed

Lines changed: 4980 additions & 943 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,34 @@ ALLOWED_SIGN_IN=""
2525
# reads Gmail and Calendar. Set both or neither — half a pair is a sign-in
2626
# button that fails at Google.
2727
#
28-
# Leave them empty only if you sign in with your own identity provider, added
29-
# on Settings → SSO. Then there is no Google button and no mail sync, and the
30-
# sign-in page says as much rather than showing you nothing.
28+
# Leave them empty only if you sign in with Microsoft below, or with your own
29+
# identity provider added on Settings → SSO. With none of the three there is no
30+
# way in at all, and the sign-in page says as much rather than showing you
31+
# nothing.
3132
GOOGLE_CLIENT_ID=""
3233
GOOGLE_CLIENT_SECRET=""
3334

35+
# Microsoft 365 / Entra ID — the other sign-in method, and the same app
36+
# registration is what reads Outlook mail. Set both or neither, exactly like
37+
# the Google pair.
38+
#
39+
# Create the app at https://portal.azure.com → Microsoft Entra ID → App
40+
# registrations, add the redirect URI <API_URL>/api/auth/callback/microsoft,
41+
# and give it the delegated Graph permissions User.Read and Mail.Read. The
42+
# README has the full walkthrough.
43+
#
44+
# You can set Google and Microsoft together: the sign-in page offers both, and
45+
# a rep's mail is read from whichever they signed in with.
46+
# MICROSOFT_CLIENT_ID=""
47+
# MICROSOFT_CLIENT_SECRET=""
48+
49+
# Which Entra tenant may sign in. "common" (the default) accepts any work,
50+
# school or personal Microsoft account and leans on ALLOWED_SIGN_IN to decide
51+
# who actually gets in; your own tenant's GUID refuses everyone else at
52+
# Microsoft, before they ever reach us. "organizations" allows any work or
53+
# school account but no personal ones.
54+
# MICROSOFT_TENANT_ID="common"
55+
3456
# Serve the landing page at "/". It markets *this* product, so it is off unless
3557
# you say otherwise: on an install of your own, a stranger arriving at the root
3658
# is sent to /sign-in instead. The only value that turns it on is "true".

‎.githooks/pre-push‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env sh
2+
3+
set -eu
4+
5+
[ -n "${CRM_SKIP_HOOKS:-}" ] && exit 0
6+
7+
deleting_only=1
8+
while read -r _local_ref local_sha _remote_ref _remote_sha; do
9+
case "$local_sha" in
10+
0000000000000000000000000000000000000000) ;;
11+
*) deleting_only=0 ;;
12+
esac
13+
done
14+
[ "$deleting_only" = "1" ] && exit 0
15+
16+
fail() {
17+
echo ""
18+
echo " \`bun run $1\` failed, so this push would have failed CI."
19+
echo " Fix it, or push anyway with --no-verify."
20+
echo ""
21+
exit 1
22+
}
23+
24+
for task in check-types lint test; do
25+
echo "pre-push: bun run $task"
26+
bun run "$task" || fail "$task"
27+
done

‎.github/release-please-config.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,5 @@
2727
"bootstrap-sha": "64f154b086cbad6cac232357dc21c954543ef217",
2828
"separate-pull-requests": false,
2929
"pull-request-title-pattern": "chore(release): ${version}",
30-
"pull-request-header": "The changelog below is what will be published as the release notes. Edit the commit subjects, not this PR body — the body is regenerated on every push to main."
30+
"pull-request-header": "The changelog below is what will be published as the release notes. Edit the commit subjects, not this PR body — the body is regenerated on every push to release."
3131
}

‎.github/scripts/pr-title.sh‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
TYPES='feat|fix|perf|refactor|docs|revert|deps|chore|test|ci|build|style'
6+
MODEL='claude-opus-5'
7+
DIFF_BYTES=40000
8+
9+
check() {
10+
local title=${1-} subject
11+
printf '%s' "$title" | grep -Eq "^($TYPES)(\([^)]+\))?!?: .+" || return 1
12+
subject=${title#*: }
13+
printf '%s' "$subject" | grep -Eq '^[A-Z][a-z]' && return 1
14+
case "$subject" in *.) return 1 ;; esac
15+
return 0
16+
}
17+
18+
scope_for() {
19+
local files=$1 scopes
20+
scopes=$(printf '%s\n' "$files" | sed -n \
21+
-e 's#^apps/\([^/]*\)/.*#\1#p' \
22+
-e 's#^packages/\([^/]*\)/.*#\1#p' \
23+
-e 's#^docs/.*#docs#p' \
24+
-e 's#^\.github/.*#ci#p' |
25+
sort -u)
26+
if [ "$(printf '%s\n' "$scopes" | grep -c .)" = "1" ]; then
27+
printf '%s' "$scopes"
28+
fi
29+
}
30+
31+
type_for() {
32+
local files=$1
33+
if ! grep -qvE '(^|/)(docs/|README|CONTRIBUTING|AGENTS|.*\.md$)' <<<"$files"; then
34+
printf 'docs'
35+
elif ! grep -qv '^\.github/' <<<"$files"; then
36+
printf 'ci'
37+
elif ! grep -qvE '(^|/)(test|tests|__tests__)/|\.spec\.|\.test\.' <<<"$files"; then
38+
printf 'test'
39+
elif ! grep -qvE '(^|/)(package\.json|bun\.lock|package-lock\.json)$' <<<"$files"; then
40+
printf 'chore'
41+
else
42+
printf 'feat'
43+
fi
44+
}
45+
46+
subject_from_branch() {
47+
local subject
48+
subject=$(printf '%s' "${1-}" | sed -e 's#^[^/]*/##' -e 's/[-_]/ /g' -e 's/\.$//' -e 's/[[:space:]]*$//')
49+
printf '%s' "$subject" | grep -Eq '^[A-Z][a-z]' &&
50+
subject=$(printf '%s' "$subject" | sed -e 's/^\(.\)/\l\1/')
51+
printf '%s' "$subject"
52+
}
53+
54+
model_title() {
55+
local base=$1 head=$2 scopes=$3 evidence body response title
56+
command -v jq >/dev/null 2>&1 || return 1
57+
58+
evidence=$(
59+
printf 'Commit subjects:\n%s\n\n' "$(git log "$base..$head" --no-merges --reverse --format='- %s')"
60+
printf 'Files changed:\n%s\n\n' "$(git diff --stat "$base..$head")"
61+
printf 'Diff:\n%s\n' "$(git diff "$base..$head" | head -c "$DIFF_BYTES")"
62+
)
63+
64+
body=$(jq -n \
65+
--arg model "$MODEL" \
66+
--arg system "You write the Conventional Commit title for a pull request that will be squashed onto main, so the title is the commit subject and the line a person reads in the changelog months from now. Write it for that reader: what the change does for them, not which files moved.
67+
68+
Format: type(scope): subject
69+
Types: feat (new capability, minor bump), fix, perf, refactor, docs, revert (patch), chore, test, ci, build, style (no release). Add ! before the colon only for a breaking change.
70+
Scopes, one only, omitted when the change spans several: $scopes.
71+
Subject: lowercase imperative, no trailing full stop, under 60 characters, no ticket numbers and no file paths.
72+
73+
Reply with the title on one line and nothing else." \
74+
--arg user "$evidence" \
75+
'{model: $model,
76+
max_tokens: 4000,
77+
system: $system,
78+
output_config: {effort: "low"},
79+
messages: [{role: "user", content: $user}]}')
80+
81+
response=$(curl -fsS --max-time 120 https://api.anthropic.com/v1/messages \
82+
-H "x-api-key: $ANTHROPIC_API_KEY" \
83+
-H 'anthropic-version: 2023-06-01' \
84+
-H 'content-type: application/json' \
85+
-d "$body") || return 1
86+
87+
[ "$(printf '%s' "$response" | jq -r '.stop_reason // ""')" = "refusal" ] && return 1
88+
89+
title=$(printf '%s' "$response" |
90+
jq -r '[.content[] | select(.type == "text") | .text] | join("")' |
91+
grep -v '^[[:space:]]*$' | head -1 |
92+
sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^`//' -e 's/`$//')
93+
94+
check "$title" || return 1
95+
printf '%s' "$title"
96+
}
97+
98+
generate() {
99+
local base=$1 head=$2 branch=${3-} files scope subject conventional title
100+
101+
files=$(git diff --name-only "$base..$head")
102+
scope=$(scope_for "$files")
103+
104+
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
105+
local allowed
106+
allowed=$( (printf '%s\n' "$files" | sed -n -e 's#^apps/\([^/]*\)/.*#\1#p' -e 's#^packages/\([^/]*\)/.*#\1#p'
107+
printf 'docs\nci\ndeps\n') | sort -u | paste -sd ',' - | sed 's/,/, /g')
108+
if title=$(model_title "$base" "$head" "$allowed"); then
109+
printf '%s\n' "$title"
110+
return 0
111+
fi
112+
echo "The model did not return a usable title — falling back to the branch name." >&2
113+
fi
114+
115+
conventional=$(
116+
while IFS= read -r subject; do
117+
if check "$subject"; then
118+
printf '%s' "$subject"
119+
break
120+
fi
121+
done < <(git log "$base..$head" --no-merges --reverse --format=%s)
122+
)
123+
if [ -n "$conventional" ]; then
124+
printf '%s\n' "$conventional"
125+
return 0
126+
fi
127+
128+
subject=$(subject_from_branch "$branch")
129+
[ -n "$subject" ] || subject="update ${files%%$'\n'*}"
130+
if [ -n "$scope" ]; then
131+
printf '%s(%s): %s\n' "$(type_for "$files")" "$scope" "$subject"
132+
else
133+
printf '%s: %s\n' "$(type_for "$files")" "$subject"
134+
fi
135+
}
136+
137+
case "${1-}" in
138+
check) check "${2-}" ;;
139+
generate) generate "${2:?base ref}" "${3:?head ref}" "${4-}" ;;
140+
*)
141+
echo "usage: pr-title.sh check <title> | pr-title.sh generate <base> <head> [branch]" >&2
142+
exit 2
143+
;;
144+
esac

‎.github/workflows/auto-pr.yml‎

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ permissions:
1919

2020
jobs:
2121
open:
22-
name: Open a draft pull request into main
22+
name: Open a pull request into main
2323
runs-on: ubuntu-24.04
2424
timeout-minutes: 5
2525
steps:
@@ -30,6 +30,7 @@ jobs:
3030
- name: Open the pull request
3131
env:
3232
GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }}
33+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
3334
BRANCH: ${{ github.ref_name }}
3435
run: |
3536
set -euo pipefail
@@ -45,23 +46,17 @@ jobs:
4546
exit 0
4647
fi
4748
48-
subject=$(git log "origin/main..origin/$BRANCH" --no-merges --reverse --format=%s | head -1)
49-
50-
if printf '%s' "$subject" | grep -Eq '^(feat|fix|perf|refactor|docs|revert|deps|chore|test|ci|build|style)(\([^)]+\))?!?: .+'; then
51-
title="$subject"
52-
else
53-
title=$(printf '%s' "${BRANCH#*/}" | tr '_' ' ' | tr '-' ' ')
54-
fi
49+
title=$(.github/scripts/pr-title.sh generate "origin/main" "origin/$BRANCH" "$BRANCH")
5550
5651
body=$(cat <<EOF
5752
Opened automatically when \`$BRANCH\` was pushed.
5853
59-
This pull request is squashed onto \`main\`, so **its title becomes the commit subject and the changelog line**. Make it a [Conventional Commit](https://www.conventionalcommits.org/) — \`feat(app): …\`, \`fix(api): …\` — before marking it ready for review, which is when the title is checked.
54+
The title is written from the diff and rewritten as you push, because this is squashed onto \`main\` and the title becomes the commit subject and the changelog line. Retitle it yourself and it is yours — the automation stops touching it.
6055
61-
The type decides the version bump: \`feat\` is a minor, \`fix\` / \`perf\` / \`refactor\` / \`docs\` are a patch, a trailing \`!\` is a major, and \`chore\` / \`ci\` / \`test\` / \`build\` / \`style\` release nothing at all.
56+
<!-- pr-title: $title -->
6257
EOF
6358
)
6459
65-
gh pr create --draft --base main --head "$BRANCH" --title "$title" --body "$body"
60+
gh pr create --base main --head "$BRANCH" --title "$title" --body "$body"
6661
67-
echo "Opened a draft pull request for \`$BRANCH\`." >> "$GITHUB_STEP_SUMMARY"
62+
echo "Opened a pull request for \`$BRANCH\` titled \`$title\`." >> "$GITHUB_STEP_SUMMARY"

‎.github/workflows/pr-base.yml‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: PR base
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, reopened]
6+
branches: [release]
7+
8+
concurrency:
9+
group: pr-base-${{ github.event.pull_request.number }}
10+
cancel-in-progress: true
11+
12+
permissions:
13+
contents: read
14+
pull-requests: write
15+
16+
jobs:
17+
retarget:
18+
name: Retarget onto main
19+
if: >-
20+
github.event.pull_request.head.ref != 'main' &&
21+
!startsWith(github.event.pull_request.head.ref, 'release-please--')
22+
runs-on: ubuntu-24.04
23+
timeout-minutes: 5
24+
steps:
25+
- name: Move the pull request to main
26+
env:
27+
GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }}
28+
GH_REPO: ${{ github.repository }}
29+
NUMBER: ${{ github.event.pull_request.number }}
30+
run: |
31+
set -euo pipefail
32+
33+
gh pr edit "$NUMBER" --base main
34+
35+
echo "Retargeted #$NUMBER from \`release\` onto \`main\`." >> "$GITHUB_STEP_SUMMARY"
36+
37+
- uses: marocchino/sticky-pull-request-comment@v2
38+
continue-on-error: true
39+
with:
40+
header: pr-base
41+
message: |
42+
**Retargeted this onto `main`.**
43+
44+
`release` is the default branch so that a plain clone runs the last tagged release, but nothing merges into it — it is fast-forwarded onto the tag by the Release workflow and that is all. Changes go to `main`, and reach `release` when a release is cut.
45+
46+
Nothing is wrong with your branch. If the diff now shows commits that are already on `main`, rebase and force-push:
47+
48+
```sh
49+
git fetch origin main
50+
git rebase origin/main
51+
git push --force-with-lease
52+
```

‎.github/workflows/pr-title.yml‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ name: PR title
33
on:
44
pull_request:
55
types: [opened, edited, reopened, ready_for_review, synchronize]
6+
branches: [main]
67

78
concurrency:
89
group: pr-title-${{ github.event.pull_request.number }}
@@ -19,8 +20,55 @@ jobs:
1920
runs-on: ubuntu-24.04
2021
timeout-minutes: 5
2122
steps:
23+
- uses: actions/checkout@v5
24+
with:
25+
fetch-depth: 0
26+
27+
- name: Write the title from the diff
28+
id: autotitle
29+
env:
30+
GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN || secrets.GITHUB_TOKEN }}
31+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
32+
ACTION: ${{ github.event.action }}
33+
TITLE: ${{ github.event.pull_request.title }}
34+
BODY: ${{ github.event.pull_request.body }}
35+
NUMBER: ${{ github.event.pull_request.number }}
36+
BRANCH: ${{ github.event.pull_request.head.ref }}
37+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
38+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
39+
run: |
40+
set -euo pipefail
41+
42+
body=$(printf '%s' "$BODY" | tr -d '\r')
43+
written=$(printf '%s\n' "$body" | sed -n 's/^<!-- pr-title: \(.*\) -->$/\1/p' | head -1)
44+
45+
if ! .github/scripts/pr-title.sh check "$TITLE"; then
46+
reason="it is not a release note"
47+
elif [ "$ACTION" != "edited" ] && [ -n "$written" ] && [ "$written" = "$TITLE" ]; then
48+
reason="the diff has moved and nobody has retitled it"
49+
else
50+
echo "\`$TITLE\` is yours — leaving it alone." >> "$GITHUB_STEP_SUMMARY"
51+
exit 0
52+
fi
53+
54+
title=$(.github/scripts/pr-title.sh generate "$BASE_SHA" "$HEAD_SHA" "$BRANCH")
55+
56+
if [ "$title" = "$TITLE" ]; then
57+
echo "\`$TITLE\` still describes the diff." >> "$GITHUB_STEP_SUMMARY"
58+
echo "written=true" >> "$GITHUB_OUTPUT"
59+
exit 0
60+
fi
61+
62+
body=$(printf '%s\n\n<!-- pr-title: %s -->\n' \
63+
"$(printf '%s\n' "$body" | grep -v '^<!-- pr-title: ' || true)" "$title")
64+
gh pr edit "$NUMBER" --title "$title" --body "$body"
65+
66+
echo "written=true" >> "$GITHUB_OUTPUT"
67+
echo "Retitled #$NUMBER to \`$title\`, because $reason." >> "$GITHUB_STEP_SUMMARY"
68+
2269
- uses: amannn/action-semantic-pull-request@v6
2370
id: lint
71+
if: steps.autotitle.outputs.written != 'true'
2472
env:
2573
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2674
with:

0 commit comments

Comments
 (0)