Skip to content

Commit f149b6e

Browse files
committed
Merge remote-tracking branch 'origin/chore/nearby-voice-chat/audio-stream-deduplication' into chore/nearby-voice-chat/audio-stream-deduplication
2 parents 2a3308a + 5d24072 commit f149b6e

20 files changed

Lines changed: 772 additions & 58 deletions

.github/workflows/claude-pr-review.yml

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ on:
88
pull_request:
99
types: [opened, synchronize, ready_for_review]
1010

11+
concurrency:
12+
group: claude-review-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
13+
cancel-in-progress: true
14+
1115
jobs:
1216
auto-review:
1317
if: |
@@ -44,11 +48,12 @@ jobs:
4448
f.filename === 'CODEOWNERS'
4549
);
4650
if (touchesSensitive) {
47-
core.notice('Auto-review disabled: PR modifies .github/prompts, .github/workflows, or CODEOWNERS. Human review required.');
51+
core.notice('Sensitive paths modified (.github/prompts, .github/workflows, or CODEOWNERS): Claude will review but not auto-approve. Human DEV review required; QA can still be waived if Claude reports QA_REQUIRED: NO.');
4852
}
4953
}
5054
51-
core.setOutput('is-auto-review', (isAutoReview && !touchesSensitive).toString());
55+
core.setOutput('is-auto-review', isAutoReview.toString());
56+
core.setOutput('touches-sensitive', touchesSensitive.toString());
5257
5358
- name: Set status to pending
5459
if: steps.check-type.outputs.is-auto-review == 'true'
@@ -121,17 +126,16 @@ jobs:
121126
with:
122127
script: |
123128
const prNumber = ${{ github.event.pull_request.number }};
129+
const owner = context.repo.owner;
130+
const repo = context.repo.repo;
131+
const touchesSensitive = '${{ steps.check-type.outputs.touches-sensitive }}' === 'true';
124132
125133
const comments = await github.rest.issues.listComments({
126-
owner: context.repo.owner,
127-
repo: context.repo.repo,
128-
issue_number: prNumber,
129-
per_page: 100
134+
owner, repo, issue_number: prNumber, per_page: 100
130135
});
131136
132-
const claudeComment = comments.data
133-
.reverse()
134-
.find(c => c.user?.login === 'claude[bot]');
137+
const claudeComments = comments.data.filter(c => c.user?.login === 'claude[bot]');
138+
const claudeComment = claudeComments[claudeComments.length - 1];
135139
136140
const output = claudeComment?.body ?? '';
137141
console.log('Claude comment preview:', output.slice(0, 300));
@@ -143,36 +147,77 @@ jobs:
143147
const hasReviewResult = output.includes('REVIEW_RESULT:');
144148
const passed = !failed && !errored;
145149
146-
if (passed && isSimple) {
147-
const approvalBody = qaRequired
148-
? 'Auto-approved by Claude — simple fix/chore with no blocking issues. QA approval is still required.'
149-
: 'Auto-approved by Claude — simple fix/chore with no blocking issues. No QA needed (non-runtime changes only).';
150-
151-
await github.rest.pulls.createReview({
152-
owner: context.repo.owner,
153-
repo: context.repo.repo,
154-
pull_number: prNumber,
155-
event: 'APPROVE',
156-
body: approvalBody
157-
});
158-
159-
const labels = ['claude-approved'];
160-
if (!qaRequired) {
161-
labels.push('no QA needed');
150+
const reviews = await github.rest.pulls.listReviews({
151+
owner, repo, pull_number: prNumber, per_page: 100
152+
});
153+
const ourApprovals = reviews.data.filter(r =>
154+
r.user?.login === 'github-actions[bot]' && r.state === 'APPROVED'
155+
);
156+
const prLabels = (context.payload.pull_request.labels || []).map(l => l.name);
157+
const COMPLEX_MARKER = '🔍 Claude reviewed this PR and found no blocking issues';
158+
const SENSITIVE_MARKER = '🔒 Claude reviewed this PR — sensitive paths modified';
159+
160+
const canAutoApprove = hasReviewResult && passed && isSimple && !touchesSensitive;
161+
const canWaiveQa = hasReviewResult && passed && isSimple && !qaRequired;
162+
163+
if (canAutoApprove) {
164+
if (ourApprovals.length === 0) {
165+
const approvalBody = qaRequired
166+
? 'Auto-approved by Claude — simple fix/chore with no blocking issues. QA approval is still required.'
167+
: 'Auto-approved by Claude — simple fix/chore with no blocking issues. No QA needed (non-runtime changes only).';
168+
await github.rest.pulls.createReview({
169+
owner, repo, pull_number: prNumber, event: 'APPROVE', body: approvalBody
170+
});
162171
}
163172
await github.rest.issues.addLabels({
164-
owner: context.repo.owner,
165-
repo: context.repo.repo,
166-
issue_number: prNumber,
167-
labels
168-
});
169-
} else if (passed && !isSimple) {
170-
await github.rest.issues.createComment({
171-
owner: context.repo.owner,
172-
repo: context.repo.repo,
173-
issue_number: prNumber,
174-
body: '🔍 Claude reviewed this PR and found no blocking issues, but assessed it as **complex** — human DEV review is still required before merging.'
173+
owner, repo, issue_number: prNumber, labels: ['claude-approved']
175174
});
175+
} else if (hasReviewResult) {
176+
for (const r of ourApprovals) {
177+
await github.rest.pulls.dismissReview({
178+
owner, repo, pull_number: prNumber, review_id: r.id,
179+
message: 'Dismissed: latest Claude review no longer auto-approves this PR.'
180+
}).catch(e => console.log(`dismissReview: ${e.message}`));
181+
}
182+
if (prLabels.includes('claude-approved')) {
183+
await github.rest.issues.removeLabel({
184+
owner, repo, issue_number: prNumber, name: 'claude-approved'
185+
}).catch(e => console.log(`removeLabel claude-approved: ${e.message}`));
186+
}
187+
if (touchesSensitive && passed && isSimple && !comments.data.some(c =>
188+
c.user?.login === 'github-actions[bot]' && c.body?.startsWith(SENSITIVE_MARKER))) {
189+
const qaNote = qaRequired
190+
? 'QA approval is still required.'
191+
: 'No QA needed (Claude reported `QA_REQUIRED: NO`).';
192+
await github.rest.issues.createComment({
193+
owner, repo, issue_number: prNumber,
194+
body: `${SENSITIVE_MARKER} (\`.github/prompts\`, \`.github/workflows\`, or \`CODEOWNERS\`). Claude will not auto-approve these PRs — human DEV review is required. ${qaNote}`
195+
});
196+
} else if (!touchesSensitive && passed && !isSimple && !comments.data.some(c =>
197+
c.user?.login === 'github-actions[bot]' && c.body?.startsWith(COMPLEX_MARKER))) {
198+
await github.rest.issues.createComment({
199+
owner, repo, issue_number: prNumber,
200+
body: COMPLEX_MARKER + ', but assessed it as **complex** — human DEV review is still required before merging.'
201+
});
202+
}
203+
}
204+
205+
if (canWaiveQa) {
206+
if (!prLabels.includes('no QA needed')) {
207+
await github.rest.issues.addLabels({
208+
owner, repo, issue_number: prNumber, labels: ['no QA needed']
209+
});
210+
}
211+
} else if (hasReviewResult && prLabels.includes('no QA needed')) {
212+
await github.rest.issues.removeLabel({
213+
owner, repo, issue_number: prNumber, name: 'no QA needed'
214+
}).catch(e => console.log(`removeLabel no QA needed: ${e.message}`));
215+
}
216+
217+
for (const c of claudeComments.slice(0, -1)) {
218+
await github.rest.issues.deleteComment({
219+
owner, repo, comment_id: c.id
220+
}).catch(e => console.log(`deleteComment ${c.id}: ${e.message}`));
176221
}
177222
178223
let description;
@@ -184,6 +229,10 @@ jobs:
184229
description = 'Claude review encountered an error';
185230
} else if (failed) {
186231
description = 'Claude found issues that must be resolved';
232+
} else if (touchesSensitive && isSimple) {
233+
description = canWaiveQa
234+
? 'No blocking issues — sensitive paths, DEV review required (no QA needed)'
235+
: 'No blocking issues — sensitive paths, DEV review required';
187236
} else if (isSimple) {
188237
description = 'No blocking issues found — PR auto-approved';
189238
} else {
@@ -373,9 +422,8 @@ jobs:
373422
per_page: 100
374423
});
375424
376-
const claudeComment = comments.data
377-
.reverse()
378-
.find(c => c.user?.login === 'claude[bot]');
425+
const claudeComments = comments.data.filter(c => c.user?.login === 'claude[bot]');
426+
const claudeComment = claudeComments[claudeComments.length - 1];
379427
380428
const output = claudeComment?.body ?? '';
381429
console.log('Claude comment preview:', output.slice(0, 200));
@@ -384,6 +432,14 @@ jobs:
384432
const errored = '${{ steps.claude.outcome }}' === 'failure';
385433
const hasReviewResult = output.includes('REVIEW_RESULT:');
386434
435+
for (const c of claudeComments.slice(0, -1)) {
436+
await github.rest.issues.deleteComment({
437+
owner: context.repo.owner,
438+
repo: context.repo.repo,
439+
comment_id: c.id
440+
}).catch(e => console.log(`deleteComment ${c.id}: ${e.message}`));
441+
}
442+
387443
let description;
388444
if (errored && !output) {
389445
description = 'Claude review failed — credit balance too low or API error';

Explorer/Assets/DCL/Communities/CommunityDataService/CommunityDataService.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using DCL.Web3.Identities;
1414
using Decentraland.SocialService.V2;
1515
using MVC;
16+
using UnityEngine.Pool;
1617
using Utility;
1718

1819
namespace DCL.Communities
@@ -34,6 +35,9 @@ public interface ICommunityDataService
3435

3536
void Clear();
3637
event Action<CommunityMetadataUpdatedEvent> CommunityMetadataUpdated;
38+
event Action<string> CommunityJoined;
39+
event Action<string> CommunityRemoved;
40+
IReadOnlyCollection<string> JoinedCommunityIds { get; }
3741
}
3842

3943
public class CommunityDataService : ICommunityDataService, IDisposable
@@ -44,11 +48,16 @@ public class CommunityDataService : ICommunityDataService, IDisposable
4448
private readonly CommunitiesDataProvider.CommunitiesDataProvider communitiesDataProvider;
4549
private readonly IWeb3IdentityCache web3IdentityCache;
4650
private readonly Dictionary<ChatChannel.ChannelId, GetUserCommunitiesData.CommunityData> communities = new();
51+
private readonly HashSet<string> joinedCommunityIds = new();
4752

4853
private CancellationTokenSource userAllowedToUseCommunityBusCts;
4954
private CancellationTokenSource communitiesServiceCts = new();
5055
private CancellationTokenSource showCommunityDeepLinkNotificationCts;
5156
public event Action<CommunityMetadataUpdatedEvent>? CommunityMetadataUpdated;
57+
public event Action<string>? CommunityJoined;
58+
public event Action<string>? CommunityRemoved;
59+
60+
public IReadOnlyCollection<string> JoinedCommunityIds => joinedCommunityIds;
5261

5362
public CommunityDataService(
5463
IChatHistory chatHistory,
@@ -139,6 +148,9 @@ private void OnCommunitiesEventBusUserDisconnectedToCommunity(CommunityMemberCon
139148
var channelId = ChatChannel.NewCommunityChannelId(userConnectivity.CommunityId);
140149
chatHistory.RemoveChannel(channelId);
141150
communities.Remove(channelId);
151+
152+
if (joinedCommunityIds.Remove(userConnectivity.CommunityId))
153+
CommunityRemoved?.Invoke(userConnectivity.CommunityId);
142154
}
143155
}
144156

@@ -151,6 +163,9 @@ private void CommunityLeft(string communityId, bool success)
151163
communities.Remove(channelId);
152164

153165
chatHistory.RemoveChannel(channelId);
166+
167+
if (joinedCommunityIds.Remove(communityId))
168+
CommunityRemoved?.Invoke(communityId);
154169
}
155170

156171
private async UniTask AddCommunityConversationAsync(string communityId, bool setAsCurrentChannel = false)
@@ -181,6 +196,9 @@ await communitiesDataProvider.GetCommunityAsync(communityId, communitiesServiceC
181196
response.data.membersCount,
182197
response.data.voiceChatStatus));
183198

199+
if (joinedCommunityIds.Add(response.data.id))
200+
CommunityJoined?.Invoke(response.data.id);
201+
184202
chatHistory.AddOrGetChannel(ChatChannel.NewCommunityChannelId(response.data.id), ChatChannel.ChatChannelType.COMMUNITY);
185203

186204
// if (setAsCurrentChannel)
@@ -205,29 +223,57 @@ private void CommunityCreated(CreateOrUpdateCommunityResponse.CommunityData newC
205223
1,
206224
new GetCommunityResponse.VoiceChatStatus());
207225

226+
if (joinedCommunityIds.Add(newCommunity.id))
227+
CommunityJoined?.Invoke(newCommunity.id);
228+
208229
chatHistory.AddOrGetChannel(channelId, ChatChannel.ChatChannelType.COMMUNITY);
209230
}
210231

211232
private void CommunityDeleted(string communityId)
212233
{
213234
var channelId = ChatChannel.NewCommunityChannelId(communityId);
214235
chatHistory.RemoveChannel(channelId);
236+
237+
if (joinedCommunityIds.Remove(communityId))
238+
CommunityRemoved?.Invoke(communityId);
215239
}
216240

217241
public void SetCommunities(IEnumerable<GetUserCommunitiesData.CommunityData> newCommunities)
218242
{
243+
using PooledObject<HashSet<string>> _ = HashSetPool<string>.Get(out HashSet<string> previouslyJoined);
244+
previouslyJoined.UnionWith(joinedCommunityIds);
245+
219246
communities.Clear();
247+
joinedCommunityIds.Clear();
248+
220249
foreach (var community in newCommunities)
221250
{
222251
communities[ChatChannel.NewCommunityChannelId(community.id)] = community;
252+
joinedCommunityIds.Add(community.id);
223253
}
254+
255+
foreach (string id in previouslyJoined)
256+
if (!joinedCommunityIds.Contains(id))
257+
CommunityRemoved?.Invoke(id);
258+
259+
foreach (string id in joinedCommunityIds)
260+
if (!previouslyJoined.Contains(id))
261+
CommunityJoined?.Invoke(id);
224262
}
225263

226264
public void Clear()
227265
{
266+
string[] previouslyJoined = joinedCommunityIds.Count == 0 ? Array.Empty<string>() : new string[joinedCommunityIds.Count];
267+
if (previouslyJoined.Length > 0)
268+
joinedCommunityIds.CopyTo(previouslyJoined);
269+
228270
communities.Clear();
271+
joinedCommunityIds.Clear();
229272
communitiesServiceCts.SafeCancelAndDispose();
230273
communitiesServiceCts = new CancellationTokenSource();
274+
275+
foreach (string id in previouslyJoined)
276+
CommunityRemoved?.Invoke(id);
231277
}
232278

233279
public bool TryGetCommunity(ChatChannel.ChannelId channelId, out GetUserCommunitiesData.CommunityData communityData)

0 commit comments

Comments
 (0)